diff --git a/.claude/plugins/test-automation/plugin.json b/.claude/plugins/test-automation/plugin.json index 50c9a0b5..4a445fe0 100644 --- a/.claude/plugins/test-automation/plugin.json +++ b/.claude/plugins/test-automation/plugin.json @@ -10,6 +10,8 @@ "skills": [ "skills/spec.md", "skills/qa-test-cases.md", - "skills/automate-tests.md" + "skills/automate-tests.md", + "skills/debug-robot-browser.md", + "skills/debug-robot-api.md" ] } diff --git a/.claude/plugins/test-automation/skills/debug-robot-api.md b/.claude/plugins/test-automation/skills/debug-robot-api.md new file mode 100644 index 00000000..7d8acab5 --- /dev/null +++ b/.claude/plugins/test-automation/skills/debug-robot-api.md @@ -0,0 +1,323 @@ +--- +name: debug-robot-api +description: This skill should be used when the user asks to "debug robot api tests", "fix a failing api test", "diagnose a robot api test failure", "why is my robot api test failing", or "run api tests and fix them". Drives a fast run-parse-fix cycle for Robot Framework API tests using RequestsLibrary. +--- + +## Purpose + +Drive a fast debug cycle for failing Robot Framework API tests: +**run with debug logging → parse output for HTTP details → identify cause → fix → verify**. + +API tests fail at the HTTP layer (status codes, response bodies, auth tokens) — not the DOM. The debug workflow is entirely different from browser tests. + +--- + +## Step 1 — Run with debug logging + +```bash +# All API tests with verbose HTTP output +cd robot_tests && pixi run robot --loglevel DEBUG api/ + +# Single suite (fastest) +cd robot_tests && pixi run robot --loglevel DEBUG api/keycloak_auth.robot + +# Single test by name +cd robot_tests && pixi run robot --loglevel DEBUG --test "TC-KC-001*" api/keycloak_auth.robot +``` + +`--loglevel DEBUG` logs the full request URL, headers, and response body for every HTTP call. Without it, you only see the status code. + +--- + +## Step 2 — Parse the failure output + +### output.xml — status codes and response bodies + +```bash +# All FAIL lines with surrounding context +python3 -c " +import xml.etree.ElementTree as ET +try: + tree = ET.parse('robot_tests/output.xml') + for msg in tree.getroot().iter('msg'): + if msg.get('level') == 'FAIL' or (msg.text and 'status' in (msg.text or '').lower()): + print(msg.text[:300]) + print('---') +except: pass +" 2>/dev/null | head -80 + +# Quick grep for HTTP status codes in output +grep -o 'status.*[0-9]\{3\}\|[0-9]\{3\}.*status\|HTTPError\|ConnectionError\|FAIL' \ + robot_tests/output.xml | sort -u | head -20 + +# Response body at failure point +grep -o '.\{0,50\}response\|body\|detail.\{0,200\}' robot_tests/output.xml | head -20 +``` + +### log.html — easiest to read + +Open in a browser for full formatted output with collapsible sections: +```bash +open robot_tests/log.html +``` + +### Backend container logs + +Often the clearest signal — shows the actual exception or validation error: +```bash +docker logs ushadow-test-backend-test-1 --tail 50 + +# Follow while running tests +docker logs ushadow-test-backend-test-1 -f +``` + +--- + +## Step 3 — Identify the failure pattern + +### Pattern A: Connection refused / backend not ready + +**Symptom:** +``` +ConnectionError: HTTPConnectionPool(host='localhost', port=8200): Max retries exceeded +``` + +**Cause:** Backend container not running or still starting. + +**Diagnosis:** +```bash +# Check container status +docker ps | grep ushadow-test + +# Check if backend responds +curl -s http://localhost:8200/health | python3 -m json.tool + +# Start containers if needed +cd robot_tests && make start +``` + +**Fix:** Wait for containers before running. The suite setup handles this — if containers are already running it reuses them. If not, run `make start` first or ensure the suite setup keyword is included. + +--- + +### Pattern B: 401 Unauthorized + +**Symptom:** +``` +Expected status 200 but got 401 +``` + +**Cause:** Missing auth token, expired token, or wrong client credentials. + +**Diagnosis:** +```bash +# Check if token endpoint works +curl -s -X POST http://localhost:8181/realms/ushadow/protocol/openid-connect/token \ + -d "grant_type=password&client_id=ushadow-frontend&username=kctest@example.com&password=TestKeycloak123!" \ + | python3 -m json.tool + +# Check if test user exists in Keycloak +TOKEN=$(curl -s -X POST http://localhost:8181/realms/master/protocol/openid-connect/token \ + -d "grant_type=password&client_id=admin-cli&username=admin&password=admin" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") +curl -s -H "Authorization: Bearer $TOKEN" \ + "http://localhost:8181/admin/realms/ushadow/users?email=kctest@example.com" | python3 -m json.tool +``` + +**Common fixes:** +- Test user not created → suite setup should call `Ensure Keycloak Test User Exists` +- Wrong client_id in token request → check `KEYCLOAK_CLIENT_ID` in `test_env.py` (should be `ushadow-frontend` or `ushadow-cli`) +- Token expired mid-suite → refresh in suite setup or request a fresh token per test + +--- + +### Pattern C: 403 Forbidden + +**Symptom:** +``` +Expected status 200 but got 403 +``` + +**Cause:** Token present but insufficient permissions, or using wrong client type. + +**Common case — introspection with public client:** +Keycloak's token introspection endpoint (`/protocol/openid-connect/token/introspect`) requires a **confidential client** with client credentials. The `ushadow-frontend` client is public and will get 401/403. + +**Fix:** +```robot +# Check status gracefully — skip if introspection not supported +${response}= POST On Session ... expected_status=any +IF '${response.status_code}' in ['401', '403'] + Skip Token introspection requires confidential client (public client limitation) +END +Should Be Equal As Integers ${response.status_code} 200 +``` + +Or use the CLI client (`ushadow-cli`) if it's configured as confidential: +```robot +# Use CLI client for introspection +${token}= Get Token Via CLI Client +``` + +--- + +### Pattern D: 404 Not Found + +**Symptom:** +``` +Expected status 200 but got 404 +``` + +**Cause:** Endpoint path is wrong, or the route doesn't exist. + +**Diagnosis:** +```bash +# Check what routes are registered on the backend +curl -s http://localhost:8200/openapi.json | python3 -c " +import sys, json +spec = json.load(sys.stdin) +for path in sorted(spec.get('paths', {}).keys()): + print(path) +" | grep -i auth + +# Or view Swagger UI +open http://localhost:8200/docs +``` + +**Fix:** Correct the endpoint path in the robot file. Check the backend's router to confirm the exact path. + +--- + +### Pattern E: Response body mismatch + +**Symptom:** +``` +'expected_field' != 'actual_field' +Should Be Equal As Strings FAIL +``` + +**Cause:** The response JSON structure changed, or the test is checking the wrong key. + +**Diagnosis:** +```bash +# Make the call manually and inspect the actual response +curl -s -X POST http://localhost:8200/api/auth/token \ + -H "Content-Type: application/json" \ + -d '{"code":"test","code_verifier":"test","redirect_uri":"http://localhost:3001/oauth/callback"}' \ + | python3 -m json.tool +``` + +Check the actual field names in the response vs what the test asserts. Common mismatches: +- `access_token` vs `token` +- `user_id` vs `id` +- Nested vs flat structure (`user.email` vs `email`) + +--- + +### Pattern F: Suite setup failure + +**Symptom:** Tests show as `NOT RUN` or the suite itself fails before any tests execute. + +**Diagnosis:** +```bash +# Check the first FAIL message +grep -m5 'FAIL\|ERROR' robot_tests/output.xml + +# Check container health +docker ps --format "table {{.Names}}\t{{.Status}}" | grep ushadow-test +``` + +**Common causes:** +- MongoDB not cleared (`✓ MongoDB test database cleared` should appear in console) +- Keycloak not reachable at `http://localhost:8181` +- Backend health check failing at `http://localhost:8200/health` + +--- + +## Step 4 — Common fixes reference + +### Auth keywords (from `resources/auth_keywords.robot`) + +```robot +# Get a token for the test user +${TOKEN}= Get Keycloak Token ${KEYCLOAK_TEST_EMAIL} ${KEYCLOAK_TEST_PASSWORD} + +# Ensure the test user exists (idempotent — safe to call in setup) +Ensure Keycloak Test User Exists + +# Create auth header dict +${HEADERS}= Create Dictionary Authorization=Bearer ${TOKEN} +``` + +### Session management + +```robot +# Create a session pointing at the backend +Create Session api ${BACKEND_URL} headers=${HEADERS} + +# Make a call +${resp}= GET On Session api /api/auth/me expected_status=200 + +# Log full response for debugging +Log Response: ${resp.status_code} ${resp.text} +``` + +### Graceful skip vs fail + +```robot +# Skip if feature not available (e.g. confidential client not configured) +${resp}= POST On Session api ${ENDPOINT} expected_status=any +Skip If '${resp.status_code}' == '501' Feature not implemented + +# Assert with helpful message +Should Be Equal As Integers ${resp.status_code} 200 +... Expected 200 but got ${resp.status_code}: ${resp.text} +``` + +--- + +## Step 5 — Fix and verify + +```bash +# Re-run with debug logging to confirm fix +cd robot_tests && pixi run robot --loglevel DEBUG api/keycloak_auth.robot +``` + +Expected output when passing: +``` +TC-KC-001: Authenticate with Keycloak Direct Grant | PASS | +TC-KC-002: OAuth Authorization Code Flow (skip) | SKIP | +TC-KC-003: Validate Access Token | PASS | +TC-KC-004: Introspect Access Token (skip) | SKIP | +TC-KC-005: Refresh Access Token | PASS | +TC-KC-006: Logout and Revoke Tokens | PASS | +6 tests, 4 passed, 0 failed, 2 skipped +``` + +--- + +## Environment quick reference + +| Service | Port | URL | +|---------|------|-----| +| Backend | 8200 | `http://localhost:8200` | +| Keycloak (test) | 8181 | `http://localhost:8181` | +| MongoDB (test) | 27118 | `mongodb://localhost:27118` | +| Redis (test) | 6480 | `redis://localhost:6480` | + +Keycloak admin: `admin` / `admin` +Test user: `kctest@example.com` / `TestKeycloak123!` +Realm: `ushadow` +Frontend client: `ushadow-frontend` (public) +CLI client: `ushadow-cli` (confidential, for direct grant) + +## Key files + +| File | Purpose | +|------|---------| +| `robot_tests/api/keycloak_auth.robot` | Keycloak auth test suite | +| `robot_tests/resources/auth_keywords.robot` | Shared auth keywords | +| `robot_tests/resources/setup/test_env.py` | URLs, ports, credentials | +| `robot_tests/resources/setup/suite_setup.robot` | Container start/stop | +| `robot_tests/output.xml` | Last run results | +| `robot_tests/log.html` | Human-readable log (open in browser) | diff --git a/.claude/plugins/test-automation/skills/debug-robot-browser.md b/.claude/plugins/test-automation/skills/debug-robot-browser.md new file mode 100644 index 00000000..5371bf40 --- /dev/null +++ b/.claude/plugins/test-automation/skills/debug-robot-browser.md @@ -0,0 +1,243 @@ +--- +name: debug-robot-browser +description: This skill should be used when the user asks to "debug robot browser tests", "fix a failing browser test", "diagnose a robot test failure", "why is my robot test failing", or "run browser tests and fix them". Drives a fast run-parse-fix cycle for Robot Framework browser tests (RF Browser / Playwright wrapper). +--- + +## Purpose + +Drive a fast debug cycle for failing Robot Framework browser tests: +**run with short timeout → parse failure output → identify cause → fix → verify**. + +Always prefer the reduced-timeout run for diagnosis. The default 15 s timeout means a single failing test wastes 15 s; 5 s surfaces the same failure 3× faster. + +--- + +## Step 1 — Run with reduced timeout + +```bash +# All browser tests (fastest diagnosis) +cd robot_tests && pixi run robot -v HEADLESS:true -v TIMEOUT:5s browser/ + +# Single test by name pattern (even faster) +cd robot_tests && pixi run robot -v HEADLESS:true -v TIMEOUT:5s --test "TC-BR-KC-002*" browser/ +``` + +| Variable | Diagnosis | Normal CI | +|----------|-----------|-----------| +| `TIMEOUT` | `5s` | `15s` (default in robot file) | +| `HEADLESS` | `true` | `true` | + +Do **not** edit the robot file to change the timeout — pass it via `-v`. + +--- + +## Step 2 — Parse the failure output + +After the run, three files contain everything needed: + +### output.xml — what failed and where + +```bash +# Failing URL at the moment of failure +grep -o '.\{0,80\}Failed at URL.\{0,120\}' robot_tests/output.xml + +# Expected selector that timed out +grep -o 'waiting for locator.*visible' robot_tests/output.xml | sort -u + +# Full test status summary +grep 'status status=' robot_tests/output.xml | grep FAIL +``` + +### playwright-log.txt — page source at failure time + +The log contains the full page HTML right after every screenshot. Extract it: + +```bash +# All Keycloak element IDs and classes (kc-*) +grep -o 'kc-[a-zA-Z-]*' robot_tests/playwright-log.txt | sort -u + +# All data-testid values present on the page +grep -o 'data-testid="[^"]*"' robot_tests/playwright-log.txt | sort -u + +# Full page source blob (first 4000 chars) +grep -o '"msg":"[^"]*"' robot_tests/playwright-log.txt | head -c 4000 + +# URL the browser was at when it failed +grep 'Failed at URL\|navigate\|goto' robot_tests/playwright-log.txt | grep localhost | tail -20 +``` + +### browser/screenshot/ — visual snapshot at failure + +```bash +ls -lt robot_tests/browser/screenshot/*.png | head -5 +# Open the most recent: open robot_tests/browser/screenshot/-failure.png +``` + +--- + +## Step 3 — Identify the failure pattern + +### Pattern A: "Invalid parameter: redirect_uri" on Keycloak page + +**Symptom:** Browser lands on Keycloak "We are sorry..." page with `redirect_uri` error instead of the login form. + +**Cause:** Race condition — the app's `SettingsContext` fetches the Keycloak URL from the backend (`/api/settings/config`, ~250 ms). If the login button is clicked before settings load, `keycloakConfig.url` is still the default `http://localhost:8081` (main env Keycloak). The OAuth request goes to the wrong Keycloak, which rejects the redirect URI. + +**Diagnosis:** Two Keycloak containers run simultaneously: +- `localhost:8081` — main dev environment (wrong for tests) +- `localhost:8181` — test environment (correct) + +**Fix:** Add `Wait For Load State networkidle` before clicking the login button. This waits until all in-flight network requests (including the settings API) settle. + +```robot +Navigate To Login And Wait For Settings + [Documentation] Navigate to /login and wait for settings API before clicking. + New Page ${WEB_URL}/login + Wait For Elements State css=[data-testid="login-button-keycloak"] visible timeout=${TIMEOUT} + Wait For Load State networkidle timeout=${TIMEOUT} + Click css=[data-testid="login-button-keycloak"] +``` + +Use this keyword instead of inline `New Page` + `Click` sequences. + +--- + +### Pattern B: Wrong selector for Keycloak error message + +**Symptom:** `waiting for locator('id=kc-error-message') to be visible` times out after submitting bad credentials. + +**Cause:** Keycloak 26 (PatternFly v5) moved the error from `
` to `
)} + {/* Force Rebuild (Docker only) */} + {mode === 'deploy' && selectedTarget?.type !== 'k8s' && ( +
+ setForceRebuild(e.target.checked)} + className="mt-0.5 h-4 w-4 rounded border-neutral-300 dark:border-neutral-600 text-primary-600 focus:ring-primary-500" + data-testid="force-rebuild-checkbox" + /> + +
+ )} + {/* Environment Variables */}
) : (
- {envVars.map((envVar) => { + {[...envVars].sort((a, b) => a.name.localeCompare(b.name)).map((envVar) => { const config = envConfigs[envVar.name] || { name: envVar.name, source: 'default', @@ -733,8 +704,13 @@ export default function DeployModal({ isOpen, onClose, onSuccess, mode = 'deploy

{mode === 'create-config' ? 'Saving configuration mappings' - : 'Creating ConfigMap, Secret, Deployment, and Service'} + : 'Building image and creating deployment...'}

+ {mode !== 'create-config' && ( +

+ This may take several minutes if building Docker images +

+ )}
) diff --git a/ushadow/frontend/src/components/DeployToK8sModal.tsx b/ushadow/frontend/src/components/DeployToK8sModal.tsx index 389a4a5a..1e1f52ba 100644 --- a/ushadow/frontend/src/components/DeployToK8sModal.tsx +++ b/ushadow/frontend/src/components/DeployToK8sModal.tsx @@ -46,6 +46,11 @@ export default function DeployToK8sModal({ isOpen, onClose, cluster: initialClus const [error, setError] = useState(null) const [deploymentResult, setDeploymentResult] = useState(null) + // Ingress configuration + const [ingressEnabled, setIngressEnabled] = useState(false) + const [ingressHostname, setIngressHostname] = useState('') + const [customHostname, setCustomHostname] = useState(false) + useEffect(() => { if (isOpen) { // If service is preselected, load env vars directly @@ -62,6 +67,26 @@ export default function DeployToK8sModal({ isOpen, onClose, cluster: initialClus } }, [isOpen, preselectedServiceId]) + // Auto-configure ingress based on cluster settings + useEffect(() => { + if (selectedService && selectedCluster) { + const hasIngressConfig = selectedCluster.ingress_domain && selectedCluster.ingress_domain.length > 0 + const shouldEnable = hasIngressConfig && (selectedCluster.ingress_enabled_by_default || false) + + setIngressEnabled(shouldEnable) + + // Auto-generate hostname + if (hasIngressConfig) { + const serviceName = selectedService.service_name + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + const autoHostname = `${serviceName}.${selectedCluster.ingress_domain}` + setIngressHostname(autoHostname) + setCustomHostname(false) + } + } + }, [selectedService, selectedCluster]) + const loadServices = async () => { try { // Use servicesApi instead of kubernetesApi to get installed compose services @@ -114,44 +139,30 @@ export default function DeployToK8sModal({ isOpen, onClose, cluster: initialClus try { console.log('📦 Selected service:', service.service_id) - console.log('🔧 Current infraServices state:', infraServices) - // Load environment variable schema with suggestions from settingsStore - const envResponse = await servicesApi.getEnvConfig(service.service_id) + // Build instanceId for this service+cluster combination + const sanitizedServiceId = service.service_id.replace(/[^a-z0-9-]/g, '-') + const clusterName = selectedCluster?.name.toLowerCase().replace(/[^a-z0-9-]/g, '-') ?? '' + const instanceId = `${sanitizedServiceId}-${clusterName}` + + // Load environment variable schema — pass deploy_target and config_id so the backend + // resolves infrastructure values AND includes previously saved deploy values + // (instance_override layer) in a single call, per the settings resolution hierarchy. + const deployTargetId = selectedCluster?.deployment_target_id + const envResponse = await servicesApi.getEnvConfig(service.service_id, deployTargetId, instanceId) const envData = envResponse.data - // Initialize env vars and configs (EXACT same pattern as ServicesPage) const allEnvVars = [...envData.required_env_vars, ...envData.optional_env_vars] setEnvVars(allEnvVars) - // Use API response data directly (backend already did smart mapping) - // ONLY override with infrastructure detection for K8s-specific values const initialConfigs: Record = {} allEnvVars.forEach(envVar => { - const infraValue = getInfraValueForEnvVar(envVar.name, infraServices) - console.log(`🔍 Checking env var ${envVar.name}:`, { infraValue, infraServices }) - - if (infraValue) { - // Override with infrastructure value for K8s cluster-specific endpoints - // Mark as locked so user can't edit - initialConfigs[envVar.name] = { - name: envVar.name, - source: 'new_setting', - value: infraValue, - new_setting_path: `api_keys.${envVar.name.toLowerCase()}`, - setting_path: undefined, - locked: true, - provider_name: 'K8s Infrastructure' - } - } else { - // Use data from API response (backend already mapped to settings) - initialConfigs[envVar.name] = { - name: envVar.name, - source: (envVar.source as 'setting' | 'new_setting' | 'literal' | 'default') || 'default', - setting_path: envVar.setting_path, - value: envVar.value, - new_setting_path: undefined - } + initialConfigs[envVar.name] = { + name: envVar.name, + source: (envVar.source as EnvVarConfig['source']) || 'default', + setting_path: envVar.setting_path, + value: envVar.resolved_value || envVar.value || '', + new_setting_path: undefined, } }) @@ -265,7 +276,15 @@ export default function DeployToK8sModal({ isOpen, onClose, cluster: initialClus { service_id: selectedService.service_id, namespace: namespace, - config_id: instanceId + config_id: instanceId, + k8s_spec: ingressEnabled ? { + ingress: { + enabled: true, + host: ingressHostname, + path: "/", + ingressClassName: selectedCluster.ingress_class || "nginx" + } + } : undefined } ) @@ -416,6 +435,63 @@ export default function DeployToK8sModal({ isOpen, onClose, cluster: initialClus

+ {/* Ingress Configuration */} + {selectedCluster?.ingress_domain && ( +
+
+ +
+ + {ingressEnabled && ( +
+
+ + {!customHostname ? ( +
+ + {ingressHostname} + + +
+ ) : ( + { + const value = e.target.value + if (/^[a-z0-9.-]*$/.test(value)) { + setIngressHostname(value) + } + }} + placeholder={`service.${selectedCluster.ingress_domain}`} + className="flex-1 px-3 py-1 text-sm rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100 font-mono" + data-testid="deploy-ingress-hostname-input" + /> + )} +
+

+ Accessible at: http://{ingressHostname} +

+
+ )} +
+ )} + {/* Environment Variables */}
) : (
- {envVars.map((envVar) => { + {[...envVars].sort((a, b) => a.name.localeCompare(b.name)).map((envVar) => { const config = envConfigs[envVar.name] || { name: envVar.name, source: 'default', diff --git a/ushadow/frontend/src/components/EnvVarEditor.tsx b/ushadow/frontend/src/components/EnvVarEditor.tsx index ce6cd3cc..8e35912f 100644 --- a/ushadow/frontend/src/components/EnvVarEditor.tsx +++ b/ushadow/frontend/src/components/EnvVarEditor.tsx @@ -1,43 +1,66 @@ import { useState } from 'react' -import { Pencil, Lock } from 'lucide-react' +import { Lock, KeyRound, Link, Pencil } from 'lucide-react' +import { Select, ComboboxItem } from '@mantine/core' import { EnvVarInfo, EnvVarConfig } from '../services/api' interface EnvVarEditorProps { envVar: EnvVarInfo config: EnvVarConfig onChange: (updates: Partial) => void - mode?: 'config' | 'deploy' | 'target' // Mode affects what options are shown + mode?: 'config' | 'deploy' | 'target' } /** - * Shared component for editing environment variable configuration. + * Layout: [label w-40] [Map|Edit btn] [icon slot] [content area] * - * Supports: - * - Mapping to existing settings (via dropdown of suggestions) - * - Manual value entry (auto-creates new settings) - * - Secret masking - * - Locked fields (provider-supplied values) + * Map/Edit button: + * - "Map" → not in mapping mode; click to show settings dropdown + * - "Edit" → in mapping mode; click to switch back to direct text entry * - * Modes: - * - 'config': Creating a ServiceConfig (shows @settings.path mappings primarily) - * - 'deploy': Deploying a service (shows runtime/deployment options, default) - * - 'target': Per-target overrides (shows target-specific values) + * Icon slot (one of): + * - Lock → locked field (provider/infra supplied) + * - Link → mapped to a settings path + * - KeyRound → secret value (unlocked, unmapped) + * - (empty) → normal field * - * Used by: - * - ServicesPage (for Docker service configuration) - * - DeployModal (for K8s/Docker deployment configuration) - * - ServiceConfigsPage (for instance configuration) + * Editing: + * - Unmapped field: click the value text to edit in place (no pencil icon) + * - Mapped field: use dropdown to change mapping, or click Edit to override with literal */ export default function EnvVarEditor({ envVar, config, onChange, mode = 'deploy' }: EnvVarEditorProps) { const [editing, setEditing] = useState(false) - // If setting_path is set, this is a "mapped" value - show mapping mode const isMapped = !!config.setting_path const [showMapping, setShowMapping] = useState(isMapped) - const isSecret = envVar.name.includes('KEY') || envVar.name.includes('SECRET') || envVar.name.includes('PASSWORD') + const isSecret = envVar.is_secret ?? false const isLocked = config.locked || envVar.locked || false - // Generate setting path from env var name for auto-creating settings + // Build deduplicated select items + value display map for the mapping dropdown. + // Mantine v9 throws (and renders nothing) if any value is duplicated in data. + const { selectData, valueDisplay } = (() => { + const seen = new Set() + const valueDisplay = new Map() + const selectData: { value: string; label: string }[] = [] + const inputLabel = (path: string, value?: string) => { + const root = path.split('.')[0] + return value ? `${root} → ${value}` : root + } + + if (config.setting_path && !envVar.suggestions.some(s => s.path === config.setting_path)) { + seen.add(config.setting_path) + selectData.push({ value: config.setting_path, label: inputLabel(config.setting_path, config.value) }) + valueDisplay.set(config.setting_path, config.value || '(current)') + } + for (const s of envVar.suggestions) { + if (!seen.has(s.path)) { + seen.add(s.path) + selectData.push({ value: s.path, label: inputLabel(s.path, s.value) }) + if (s.value) valueDisplay.set(s.path, s.value) + } + } + return { selectData, valueDisplay } + })() + const autoSettingPath = () => { const name = envVar.name.toLowerCase() if (name.includes('api_key') || name.includes('key') || name.includes('secret') || name.includes('token')) { @@ -46,7 +69,6 @@ export default function EnvVarEditor({ envVar, config, onChange, mode = 'deploy' return `settings.${name}` } - // Handle value input - auto-create setting const handleValueChange = (value: string) => { if (value) { onChange({ source: 'new_setting', new_setting_path: autoSettingPath(), value, setting_path: undefined }) @@ -55,47 +77,34 @@ export default function EnvVarEditor({ envVar, config, onChange, mode = 'deploy' } } - // Locked fields - provided by wired providers or infrastructure - if (isLocked) { - const displayValue = config.value || '' - const isMaskedSecret = isSecret && displayValue.length > 0 - const maskedValue = isMaskedSecret ? '•'.repeat(Math.min(displayValue.length, 20)) : displayValue + const handleSwitchToEdit = () => { + setShowMapping(false) + setEditing(true) + // Clear the mapping so the value becomes a literal override + onChange({ source: 'new_setting', setting_path: undefined, new_setting_path: autoSettingPath(), value: config.value }) + } - return ( -
- {/* Label */} - - {envVar.name} - {envVar.is_required && *} - + // Row background + const rowBg = isSecret + ? 'bg-purple-50 dark:bg-purple-900/10' + : isLocked + ? 'bg-blue-50 dark:bg-blue-900/10' + : 'bg-white dark:bg-neutral-800' - {/* Padlock icon */} -
- -
+ // Icon slot + const iconSlot = isLocked + ? + : showMapping + ? + : isSecret + ? + : - {/* Value display */} -
- - {maskedValue} - - - {config.provider_name || 'provider'} - -
-
- ) - } + const displayValue = config.value || '' return (
{/* Label */} @@ -107,99 +116,130 @@ export default function EnvVarEditor({ envVar, config, onChange, mode = 'deploy' {envVar.is_required && *} - {/* Map button - LEFT of input */} + {/* Map / Edit toggle button */} - {/* Input area */} + {/* Icon slot */} + {iconSlot} + + {/* Content area */}
{showMapping ? ( - // Mapping mode - styled dropdown - { + if (val) { onChange({ source: 'setting', - setting_path: e.target.value, + setting_path: val, value: undefined, new_setting_path: undefined, }) } }} - className="flex-1 min-w-0 px-2 py-1.5 text-xs font-mono rounded border-0 bg-neutral-700/50 text-neutral-200 focus:outline-none focus:ring-1 focus:ring-primary-500 cursor-pointer overflow-hidden text-ellipsis" - data-testid={`map-select-${envVar.name}`} - > - - {/* If current setting_path isn't in suggestions, show it as an option */} - {config.setting_path && !envVar.suggestions.some(s => s.path === config.setting_path) && ( - + data={selectData} + placeholder="select..." + w="100%" + renderOption={({ option }: { option: ComboboxItem }) => ( +
+ + {valueDisplay.get(option.value) || '—'} + + + {option.value} + +
)} - {envVar.suggestions.map((s) => { - // Truncate long values to prevent horizontal scrolling - const displayValue = s.value && s.value.length > 30 ? s.value.substring(0, 30) + '...' : s.value - return ( - - ) - })} - - ) : config.value && !editing ? ( - // Has resolved value - show with source badge + size="xs" + variant="filled" + styles={{ + input: { + fontSize: '0.75rem', + fontFamily: 'monospace', + cursor: 'pointer', + backgroundColor: 'var(--mantine-color-dark-6)', + color: 'var(--mantine-color-dark-0)', + border: 'none', + }, + }} + comboboxProps={{ zIndex: 10000, width: 400, position: 'bottom-start' }} + data-testid={`map-select-${envVar.name}`} + /> + ) : isLocked && !editing ? ( + // Locked: read-only value + source badge <> - - {isSecret ? '•'.repeat(Math.min(config.value.length, 20)) : config.value} + + {isSecret ? '•'.repeat(Math.min((config.value || '').length, 20)) : config.value} + + + {config.provider_name || 'provider'} + + + ) : editing || !displayValue ? ( + // Editing or empty — text input + handleValueChange(e.target.value)} + placeholder="enter value" + className="flex-1 px-2 py-1.5 text-xs rounded border-0 bg-neutral-100 dark:bg-neutral-700/50 text-neutral-900 dark:text-neutral-200 focus:outline-none focus:ring-1 focus:ring-primary-500 placeholder:text-neutral-400 dark:placeholder:text-neutral-500" + autoFocus={editing} + onFocus={() => setEditing(true)} + onBlur={() => setEditing(false)} + data-testid={`value-input-${envVar.name}`} + /> + ) : ( + // Has value, not editing — click to edit + <> + setEditing(true)} + data-testid={`value-display-${envVar.name}`} + > + {displayValue} {config.source === 'env_file' ? '.env' : config.source === 'capability' ? 'provider' : config.source === 'infra' ? 'infra' : config.source === 'config_default' ? 'config' : - config.source === 'compose_default' ? 'default' : config.source === 'default' ? 'default' : config.source} - ) : ( - // No value or editing - show input - handleValueChange(e.target.value)} - placeholder="enter value" - className="flex-1 px-2 py-1.5 text-xs rounded border-0 bg-neutral-700/50 text-neutral-200 focus:outline-none focus:ring-1 focus:ring-primary-500 placeholder:text-neutral-500" - autoFocus={editing} - onBlur={() => setEditing(false)} - data-testid={`value-input-${envVar.name}`} - /> )}
diff --git a/ushadow/frontend/src/components/InfrastructureOverridesEditor.tsx b/ushadow/frontend/src/components/InfrastructureOverridesEditor.tsx new file mode 100644 index 00000000..df07389b --- /dev/null +++ b/ushadow/frontend/src/components/InfrastructureOverridesEditor.tsx @@ -0,0 +1,189 @@ +/** + * InfrastructureOverridesEditor - Shows all infrastructure env vars with ability to override. + * + * Uses the unified deploy-target endpoint which returns already-composited vars with + * source attribution (default | infrastructure | override). No frontend compositing needed. + */ + +import { useState, useEffect } from 'react' +import { Save, X, AlertCircle, Loader } from 'lucide-react' +import EnvVarEditor from './EnvVarEditor' +import type { EnvVarInfo, EnvVarConfig } from '../services/api' +import { deploymentTargetsApi } from '../services/api' + +interface InfrastructureOverridesEditorProps { + /** Deployment target ID (e.g. "anubis.k8s.purple") — NOT cluster_id */ + targetId: string + targetName: string + onSave?: () => void + onCancel?: () => void +} + +export default function InfrastructureOverridesEditor({ + targetId, + targetName, + onSave, + onCancel, +}: InfrastructureOverridesEditorProps) { + const [allEnvVars, setAllEnvVars] = useState([]) + const [envConfigs, setEnvConfigs] = useState>({}) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + const load = async () => { + try { + setLoading(true) + const response = await deploymentTargetsApi.getInfrastructureEnvVars(targetId) + const { env_vars } = response.data + + const vars: EnvVarInfo[] = [] + const configs: Record = {} + + env_vars.forEach(ev => { + vars.push({ + name: ev.name, + is_required: false, + source: ev.source, + resolved_value: ev.value, + suggestions: [], + is_secret: ev.is_secret, + }) + + configs[ev.name] = { + name: ev.name, + source: ev.source, + value: ev.value, + locked: ev.locked, + provider_name: ev.locked ? targetName : undefined, + } + }) + + setAllEnvVars(vars) + setEnvConfigs(configs) + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to load infrastructure configuration') + } finally { + setLoading(false) + } + } + + load() + }, [targetId, targetName]) + + const handleEnvConfigChange = (name: string, updates: Partial) => { + setEnvConfigs(prev => ({ + ...prev, + [name]: { ...(prev[name] || { name }), ...updates } as EnvVarConfig, + })) + } + + const handleSave = async () => { + try { + setSaving(true) + setError(null) + + // Save all non-locked values — compose defaults, overrides, and user-entered values. + // Infra-scan locked values (HOST, PORT, URL from cluster scan) are excluded. + // This ensures compose defaults (e.g. MONGODB_USERNAME=root) are persisted so + // _from_setting references to infrastructure.overrides.{cluster}.* resolve correctly. + const overrides: Record = {} + Object.entries(envConfigs).forEach(([name, config]) => { + if (!config.locked && config.value) { + overrides[name] = config.value + } + }) + + await deploymentTargetsApi.saveInfrastructureOverrides(targetId, overrides) + onSave?.() + } catch (err: any) { + setError(err.response?.data?.detail || err.message || 'Failed to save overrides') + } finally { + setSaving(false) + } + } + + if (loading) { + return ( +
+ + Loading infrastructure configuration... +
+ ) + } + + if (allEnvVars.length === 0) { + return ( +
+ +

No infrastructure services found in docker-compose.infra.yml

+
+ ) + } + + return ( +
+ {error && ( +
+ +

{error}

+
+ )} + +
+ Infrastructure endpoints for {targetName}. + Locked values come from infrastructure scans. Click to override. +
+ +
+ {allEnvVars + .sort((a, b) => a.name.localeCompare(b.name)) + .map(envVar => { + const config = envConfigs[envVar.name] || { + name: envVar.name, + source: 'default', + value: undefined, + } + return ( +
+ handleEnvConfigChange(envVar.name, updates)} + mode="deploy" + /> +
+ ) + })} +
+ +
+ Locked values come from infrastructure scans. Click the padlock to override for this deploy target. +
+ +
+ {onCancel && ( + + )} + +
+
+ ) +} diff --git a/ushadow/frontend/src/components/ShareDialog.tsx b/ushadow/frontend/src/components/ShareDialog.tsx new file mode 100644 index 00000000..dbe61b2c --- /dev/null +++ b/ushadow/frontend/src/components/ShareDialog.tsx @@ -0,0 +1,329 @@ +import React, { useState } from 'react' +import { useForm, Controller } from 'react-hook-form' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Copy, Check, Trash2 } from 'lucide-react' +import Modal from './Modal' +import { SettingField } from './settings/SettingField' +import ConfirmDialog from './ConfirmDialog' +import { api } from '../services/api' + +interface ShareToken { + token: string + share_url: string + resource_type: string + resource_id: string + permissions: string[] + expires_at: string | null + max_views: number | null + view_count: number + require_auth: boolean + tailscale_only: boolean + created_at: string +} + +interface ShareDialogProps { + isOpen: boolean + onClose: () => void + resourceType: 'conversation' | 'memory' | 'collection' + resourceId: string +} + +interface ShareFormData { + expires_in_days: number | null + max_views: number | null + require_auth: boolean + tailscale_only: boolean + permissions: string[] +} + +const ShareDialog: React.FC = ({ + isOpen, + onClose, + resourceType, + resourceId, +}) => { + const [copiedToken, setCopiedToken] = useState(null) + const [revokeToken, setRevokeToken] = useState(null) + const queryClient = useQueryClient() + + const { control, handleSubmit, reset } = useForm({ + defaultValues: { + expires_in_days: 7, + max_views: null, + require_auth: false, + tailscale_only: false, + permissions: ['read'], + }, + }) + + // Fetch existing shares for this resource + const { data: shares, isLoading: loadingShares } = useQuery({ + queryKey: ['shares', resourceType, resourceId], + queryFn: async () => { + const response = await api.get(`/api/share/resource/${resourceType}/${resourceId}`) + return response.data + }, + enabled: isOpen, + }) + + // Create share token mutation + const createShareMutation = useMutation({ + mutationFn: async (data: ShareFormData) => { + const response = await api.post('/api/share/create', { + resource_type: resourceType, + resource_id: resourceId, + ...data, + }) + return response.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['shares', resourceType, resourceId] }) + reset() + }, + onError: (error: any) => { + console.error('[ShareDialog] Create share failed:', error) + console.error('[ShareDialog] Error response:', error.response?.data) + }, + }) + + // Revoke share token mutation + const revokeShareMutation = useMutation({ + mutationFn: async (token: string) => { + await api.delete(`/api/share/${token}`) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['shares', resourceType, resourceId] }) + setRevokeToken(null) + }, + }) + + const handleCopyLink = async (shareUrl: string, token: string) => { + await navigator.clipboard.writeText(shareUrl) + setCopiedToken(token) + setTimeout(() => setCopiedToken(null), 2000) + } + + const handleCreateShare = handleSubmit(async (data) => { + console.log('[ShareDialog] Creating share with data:', data) + await createShareMutation.mutateAsync(data) + }) + + const handleRevokeShare = async () => { + if (revokeToken) { + await revokeShareMutation.mutateAsync(revokeToken) + } + } + + return ( + <> + +
+ {/* Create new share section */} +
+

+ Create New Share Link +

+ +
+
+ ( + field.onChange(v ? Number(v) : null)} + options={[ + { value: '', label: 'Never' }, + { value: '1', label: '1 day' }, + { value: '7', label: '7 days' }, + { value: '30', label: '30 days' }, + { value: '90', label: '90 days' }, + ]} + /> + )} + /> + + ( + field.onChange(v ? Number(v) : null)} + /> + )} + /> +
+ +
+ ( + + )} + /> + + ( + + )} + /> +
+ + + + {createShareMutation.isError && ( +

+ {(createShareMutation.error as any)?.response?.data?.detail || createShareMutation.error?.message || 'Failed to create share link'} +

+ )} +
+
+ + {/* Existing shares section */} +
+

+ Existing Share Links +

+ + {loadingShares ? ( +

Loading shares...

+ ) : shares && shares.length > 0 ? ( +
+ {shares.map((share) => ( +
+
+
+

+ {share.share_url} +

+
+ + Views: {share.view_count}{share.max_views ? `/${share.max_views}` : ''} + + {share.expires_at && ( + + Expires: {new Date(share.expires_at).toLocaleDateString()} + + )} + {/* Access mode badges */} + {share.require_auth ? ( + + Private + + ) : ( + + Public + + )} + {share.tailscale_only && ( + + Tailscale + + )} +
+
+ +
+ + + +
+
+
+ ))} +
+ ) : ( +

+ No active share links. Create one above to get started. +

+ )} +
+
+
+ + setRevokeToken(null)} + onConfirm={handleRevokeShare} + title="Revoke Share Link?" + message="Anyone with this link will lose access immediately. This action cannot be undone." + variant="danger" + confirmText="Revoke Access" + testId="share-dialog-revoke-confirm" + /> + + ) +} + +export default ShareDialog diff --git a/ushadow/frontend/src/components/auth/AuthHeader.tsx b/ushadow/frontend/src/components/auth/AuthHeader.tsx index fa7db1df..728c2d06 100644 --- a/ushadow/frontend/src/components/auth/AuthHeader.tsx +++ b/ushadow/frontend/src/components/auth/AuthHeader.tsx @@ -35,7 +35,7 @@ export default function AuthHeader({ subtitle }: AuthHeaderProps) { uShadow Logo { // Fallback to icon if logo doesn't load @@ -53,7 +53,7 @@ export default function AuthHeader({ subtitle }: AuthHeaderProps) {

- } + console.log('[ProtectedRoute] Auth check:', { + pathname: location.pathname, + isAuthenticated, + willRedirect: !isAuthenticated + }) - if (!token || !user) { + if (!isAuthenticated) { + console.log('[ProtectedRoute] Not authenticated, redirecting to login from:', location.pathname) // Preserve the intended destination so login can redirect back return } - if (adminOnly && !isAdmin) { + // TODO: Implement role-based admin check if needed + if (adminOnly) { return (
@@ -37,7 +40,7 @@ export default function ProtectedRoute({ children, adminOnly = false }: Protecte Access Denied

- You don't have permission to access this page. + Admin-only feature (role check not yet implemented)

diff --git a/ushadow/frontend/src/components/chronicle/ChronicleRecording.tsx b/ushadow/frontend/src/components/chronicle/ChronicleRecording.tsx index f2134e31..37388e0b 100644 --- a/ushadow/frontend/src/components/chronicle/ChronicleRecording.tsx +++ b/ushadow/frontend/src/components/chronicle/ChronicleRecording.tsx @@ -261,9 +261,15 @@ export default function ChronicleRecording({ onAuthRequired, recording }: Chroni }

{recording.mode === 'dual-stream' && ( -

- You'll be prompted to select a browser tab or screen to capture audio from. -

+
+

⚠️ Important: Select "Chrome Tab" (not "Your Entire Screen")

+
    +
  1. Click "Chrome Tab" at the top of the picker
  2. +
  3. Select the tab with audio (YouTube, meeting, etc.)
  4. +
  5. Check "Share tab audio" at the bottom
  6. +
  7. Click Share
  8. +
+
)} @@ -355,6 +361,30 @@ export default function ChronicleRecording({ onAuthRequired, recording }: Chroni )} + {/* Live Transcript */} + {recording.liveTranscript.some(e => e.finalText || e.interimText) && ( +
+

Live Transcript

+
+ {recording.liveTranscript.map(entry => ( +
+ + {entry.source} + +

+ {entry.finalText} + {entry.interimText && ( + + {entry.interimText} + + )} +

+
+ ))} +
+
+ )} + {/* Debug Stats */} {(recording.isRecording || recording.debugStats.chunksSent > 0) && (
diff --git a/ushadow/frontend/src/components/conversations/ConversationCard.tsx b/ushadow/frontend/src/components/conversations/ConversationCard.tsx new file mode 100644 index 00000000..b0488fb4 --- /dev/null +++ b/ushadow/frontend/src/components/conversations/ConversationCard.tsx @@ -0,0 +1,102 @@ +import { MessageSquare, Calendar, FileText, Brain } from 'lucide-react' +import type { Conversation } from '../../services/chronicleApi' +import type { ConversationSource } from '../../hooks/useConversations' + +interface ConversationCardProps { + conversation: Conversation + source: ConversationSource + onClick?: () => void +} + +export default function ConversationCard({ conversation, source, onClick }: ConversationCardProps) { + const { + conversation_id, + title, + summary, + created_at, + segment_count, + memory_count, + has_memory, + } = conversation + + // Extract start time - Mycelia uses started_at (timeRanges[0].start) for actual conversation time + // created_at in Mycelia is the processing timestamp, not the conversation time + const myceliaConv = conversation as any + const conversationDate = myceliaConv?.started_at || created_at + + // Format date + const date = conversationDate ? new Date(conversationDate) : null + const formattedDate = date + ? date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: date.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined, + }) + + ' ' + + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) + : 'Unknown date' + + // Source badge color + const sourceBadgeClass = + source === 'chronicle' + ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300' + : 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300' + + return ( +
+ {/* Header */} +
+
+ +

+ {title || 'Untitled conversation'} +

+
+ + {source} + +
+ + {/* Summary */} + {summary && ( +

+ {summary} +

+ )} + + {/* Footer metadata */} +
+
+ {/* Date */} +
+ + {formattedDate} +
+ + {/* Segment count */} + {segment_count !== undefined && segment_count > 0 && ( +
+ + {segment_count} +
+ )} +
+ + {/* Memory indicator */} + {has_memory && memory_count !== undefined && memory_count > 0 && ( +
+ + {memory_count} +
+ )} +
+
+ ) +} diff --git a/ushadow/frontend/src/components/feed/AddSourceModal.tsx b/ushadow/frontend/src/components/feed/AddSourceModal.tsx new file mode 100644 index 00000000..a3213e3b --- /dev/null +++ b/ushadow/frontend/src/components/feed/AddSourceModal.tsx @@ -0,0 +1,325 @@ +/** + * AddSourceModal — form to add a content source (Mastodon instance or YouTube API key). + * + * Platform selector at the top switches between Mastodon fields (instance URL) + * and YouTube fields (API key via SecretInput). + */ + +import { useState, useEffect } from 'react' +import { Radio, Loader2, MessageSquare, Play, Cloud } from 'lucide-react' +import Modal from '../Modal' +import { SecretInput } from '../settings/SecretInput' +import type { SourceCreateData } from '../../services/feedApi' + +type PlatformType = 'mastodon' | 'youtube' | 'bluesky' | 'bluesky_timeline' + +interface AddSourceModalProps { + isOpen: boolean + onClose: () => void + onAdd: (data: SourceCreateData) => Promise + isAdding: boolean + defaultPlatform?: PlatformType +} + +export default function AddSourceModal({ isOpen, onClose, onAdd, isAdding, defaultPlatform }: AddSourceModalProps) { + const [platformType, setPlatformType] = useState(defaultPlatform ?? 'mastodon') + const [name, setName] = useState('') + const [instanceUrl, setInstanceUrl] = useState('') + const [apiKey, setApiKey] = useState('') + const [handle, setHandle] = useState('') + const [error, setError] = useState(null) + + // Sync platform when modal opens with a new default + useEffect(() => { + if (isOpen && defaultPlatform) { + setPlatformType(defaultPlatform) + } + }, [isOpen, defaultPlatform]) + + const resetForm = () => { + setName('') + setInstanceUrl('') + setApiKey('') + setHandle('') + setError(null) + } + + const handlePlatformChange = (type: PlatformType) => { + setPlatformType(type) + resetForm() + } + + const isValid = + platformType === 'mastodon' ? instanceUrl.trim().length > 0 + : platformType === 'bluesky' ? true + : platformType === 'bluesky_timeline' ? handle.trim().length > 0 && apiKey.trim().length > 0 + : apiKey.trim().length > 0 + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + try { + if (platformType === 'mastodon') { + let url = instanceUrl.trim() + if (!url.startsWith('http')) url = `https://${url}` + url = url.replace(/\/+$/, '') + + await onAdd({ + name: name.trim() || url.replace(/^https?:\/\//, ''), + platform_type: 'mastodon', + instance_url: url, + }) + } else if (platformType === 'bluesky') { + const pdsUrl = instanceUrl.trim() + let url = pdsUrl + if (url && !url.startsWith('http')) url = `https://${url}` + url = url.replace(/\/+$/, '') + + await onAdd({ + name: name.trim() || 'Bluesky', + platform_type: 'bluesky', + instance_url: url || undefined, + }) + } else if (platformType === 'bluesky_timeline') { + const h = handle.trim().replace(/^@/, '') // normalise @user.bsky.social → user.bsky.social + await onAdd({ + name: name.trim() || `@${h}`, + platform_type: 'bluesky_timeline', + handle: h, + api_key: apiKey.trim(), + }) + } else { + await onAdd({ + name: name.trim() || 'YouTube', + platform_type: 'youtube', + api_key: apiKey.trim(), + }) + } + + resetForm() + onClose() + } catch (err: any) { + setError(err?.response?.data?.detail || 'Failed to add source') + } + } + + return ( + } + maxWidth="sm" + testId="add-source-modal" + > +
+ {/* Platform selector */} +
+ +
+ + + + +
+
+ + {/* Platform-specific fields */} + {platformType === 'mastodon' ? ( +
+ + setInstanceUrl(e.target.value)} + required + className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100 text-sm focus:ring-2 focus:ring-primary-500 focus:outline-none" + data-testid="add-source-url-input" + /> +

+ Any Mastodon-compatible server (e.g., mastodon.social, fosstodon.org) +

+
+ ) : platformType === 'bluesky' ? ( +
+ + setInstanceUrl(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100 text-sm focus:ring-2 focus:ring-primary-500 focus:outline-none" + data-testid="add-source-url-input" + /> +

+ Leave blank to use the default public Bluesky AppView. Posts are fetched + by your interest hashtags — no account required. +

+
+ ) : platformType === 'bluesky_timeline' ? ( +
+
+ + setHandle(e.target.value)} + required + className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100 text-sm focus:ring-2 focus:ring-primary-500 focus:outline-none" + data-testid="add-source-handle-input" + /> +
+
+ + +

+ Create one at Settings → App Passwords on bsky.app. Never use your main password. +

+
+
+ ) : ( +
+ + +

+ Get one from{' '} + + Google Cloud Console + + {' '}→ YouTube Data API v3 +

+
+ )} + + {/* Display name (shared) */} +
+ + setName(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100 text-sm focus:ring-2 focus:ring-primary-500 focus:outline-none" + data-testid="add-source-name-input" + /> +
+ + {error && ( +

{error}

+ )} + +
+ + +
+
+
+ ) +} diff --git a/ushadow/frontend/src/components/feed/BlueskyComposeModal.tsx b/ushadow/frontend/src/components/feed/BlueskyComposeModal.tsx new file mode 100644 index 00000000..b5d8d7f0 --- /dev/null +++ b/ushadow/frontend/src/components/feed/BlueskyComposeModal.tsx @@ -0,0 +1,122 @@ +/** + * BlueskyComposeModal — compose and post to Bluesky, or reply to a post. + * + * Opened from the "Post" button (new post) or the reply button on any + * Bluesky post card. Calls /api/feed/bluesky/post or + * /api/feed/bluesky/reply/{post_id} depending on mode. + */ + +import { useState } from 'react' +import { Cloud, Loader2 } from 'lucide-react' +import Modal from '../Modal' +import { feedApi } from '../../services/feedApi' + +const CHAR_LIMIT = 300 + +interface BlueskyComposeModalProps { + isOpen: boolean + onClose: () => void + sourceId: string + /** When set, the modal is in reply mode — replying to this post. */ + replyTo?: { postId: string; handle: string } +} + +export default function BlueskyComposeModal({ + isOpen, + onClose, + sourceId, + replyTo, +}: BlueskyComposeModalProps) { + const [text, setText] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + + const isReply = !!replyTo + const charsRemaining = CHAR_LIMIT - text.length + + const handleClose = () => { + setText('') + setError(null) + onClose() + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!text.trim() || charsRemaining < 0) return + + setIsSubmitting(true) + setError(null) + try { + if (isReply && replyTo) { + await feedApi.bskyReply(replyTo.postId, { source_id: sourceId, text: text.trim() }) + } else { + await feedApi.bskyPost({ source_id: sourceId, text: text.trim() }) + } + handleClose() + } catch (err: any) { + setError(err?.response?.data?.detail || 'Failed to post — check your app password and try again') + } finally { + setIsSubmitting(false) + } + } + + return ( + } + maxWidth="md" + testId="bluesky-compose-modal" + > +
+ + {/* ---------------------------------------------------------------- + TODO: Implement the compose text area below. + + The submit handler and char limit (CHAR_LIMIT = 300) are wired up. + What we need here is the text input area and character counter. + + Things to consider: + - When should the counter turn red? At 0? At -N (over limit)? + - Should the textarea auto-resize as the user types, or be fixed height? + - Should we show a reply context preview above the textarea + when isReply=true (showing who they're replying to)? + + `text` and `setText` are already declared above. + `charsRemaining` = 300 - text.length (negative means over limit). + + Expected testid patterns (per CLAUDE.md convention): + data-testid="bluesky-compose-textarea" + data-testid="bluesky-compose-char-count" + ---------------------------------------------------------------- */} + + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+
+ ) +} diff --git a/ushadow/frontend/src/components/feed/FeedEmptyState.tsx b/ushadow/frontend/src/components/feed/FeedEmptyState.tsx new file mode 100644 index 00000000..cee33417 --- /dev/null +++ b/ushadow/frontend/src/components/feed/FeedEmptyState.tsx @@ -0,0 +1,79 @@ +/** + * FeedEmptyState — shown when no sources are configured or no posts are available. + * Platform-aware: messaging adapts to whether the user is on Social or Videos tab. + */ + +import { Radio, Plus, Loader2, MessageSquare, Play } from 'lucide-react' + +const PLATFORM_LABELS: Record = { + mastodon: { + name: 'Mastodon', + guidance: 'Add a Mastodon-compatible server to start curating your personalized feed based on your knowledge graph interests.', + }, + youtube: { + name: 'YouTube', + guidance: 'Add a YouTube API key to discover videos ranked by your knowledge graph interests.', + }, +} + +interface FeedEmptyStateProps { + hasSources: boolean + platformType?: string + onAddSource: () => void + onRefresh: () => void + isRefreshing: boolean +} + +export default function FeedEmptyState({ + hasSources, + platformType, + onAddSource, + onRefresh, + isRefreshing, +}: FeedEmptyStateProps) { + const label = platformType ? PLATFORM_LABELS[platformType] : undefined + const Icon = platformType === 'youtube' ? Play : MessageSquare + + if (!hasSources) { + return ( +
+ +

+ No {label?.name ?? ''} sources configured +

+

+ {label?.guidance ?? 'Add a content source to get started.'} +

+ +
+ ) + } + + return ( +
+ +

+ No {label?.name ? `${label.name} ` : ''}posts yet +

+

+ Hit refresh to fetch posts from your sources and score them against your interests. +

+ +
+ ) +} diff --git a/ushadow/frontend/src/components/feed/InterestChip.tsx b/ushadow/frontend/src/components/feed/InterestChip.tsx new file mode 100644 index 00000000..b22d7552 --- /dev/null +++ b/ushadow/frontend/src/components/feed/InterestChip.tsx @@ -0,0 +1,28 @@ +/** + * InterestChip — clickable filter chip for a knowledge-graph interest. + */ + +import type { FeedInterest } from '../../services/feedApi' + +interface InterestChipProps { + interest: FeedInterest + active: boolean + onClick: () => void +} + +export default function InterestChip({ interest, active, onClick }: InterestChipProps) { + return ( + + ) +} diff --git a/ushadow/frontend/src/components/feed/PostCard.tsx b/ushadow/frontend/src/components/feed/PostCard.tsx new file mode 100644 index 00000000..bec00c57 --- /dev/null +++ b/ushadow/frontend/src/components/feed/PostCard.tsx @@ -0,0 +1,184 @@ +/** + * PostCard — renders a single fediverse post with actions and relevance info. + */ + +import { Bookmark, Eye, ExternalLink, Clock, MessageCircle } from 'lucide-react' +import type { FeedPost } from '../../services/feedApi' + +interface PostCardProps { + post: FeedPost + onBookmark: (postId: string) => void + onMarkSeen: (postId: string) => void + /** Called when reply button is clicked (only provided for authenticated Bluesky sources) */ + onReply?: (postId: string, handle: string) => void +} + +function timeAgo(dateStr: string): string { + const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000) + if (seconds < 60) return 'just now' + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h` + const days = Math.floor(hours / 24) + return `${days}d` +} + +/** Strip HTML tags for a plain-text excerpt using regex (no DOM / no innerHTML). */ +function stripHtml(html: string): string { + return html + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +export default function PostCard({ post, onBookmark, onMarkSeen, onReply }: PostCardProps) { + const plainText = stripHtml(post.content) + + return ( +
+ {/* Header: author info */} +
+ {post.author_avatar ? ( + {post.author_display_name} + ) : ( +
+ {(post.author_display_name || post.author_handle).charAt(0).toUpperCase()} +
+ )} +
+
+ + {post.author_display_name || post.author_handle} + + + {post.author_handle} + +
+
+ + {timeAgo(post.published_at)} +
+
+ {/* Relevance score pill */} + {post.relevance_score > 0 && ( + + {post.relevance_score.toFixed(1)} + + )} +
+ + {/* Content */} +

+ {plainText} +

+ + {/* Matched interests */} + {post.matched_interests.length > 0 && ( +
+ Matched: + {post.matched_interests.map((name) => ( + + {name} + + ))} +
+ )} + + {/* Hashtags */} + {post.hashtags.length > 0 && ( +
+ {post.hashtags.slice(0, 5).map((tag) => ( + + #{tag} + + ))} +
+ )} + + {/* Actions */} +
+
+ {/* Bookmark */} + + + {/* Mark seen */} + {!post.seen && ( + + )} + + {/* Reply — only shown when authenticated Bluesky source is available */} + {onReply && (post.platform_type === 'bluesky' || post.platform_type === 'bluesky_timeline') && ( + + )} +
+ + {/* Link to original */} + + + Original + +
+
+ ) +} diff --git a/ushadow/frontend/src/components/feed/YouTubePostCard.tsx b/ushadow/frontend/src/components/feed/YouTubePostCard.tsx new file mode 100644 index 00000000..5ef1e19d --- /dev/null +++ b/ushadow/frontend/src/components/feed/YouTubePostCard.tsx @@ -0,0 +1,216 @@ +/** + * YouTubePostCard — renders a YouTube video card with thumbnail, stats, and actions. + * + * Horizontal layout: thumbnail left, metadata right. + * Reuses bookmark/seen actions from PostCard pattern. + */ + +import { Bookmark, Eye, ExternalLink, Clock, ThumbsUp, Play } from 'lucide-react' +import type { FeedPost } from '../../services/feedApi' + +interface YouTubePostCardProps { + post: FeedPost + onBookmark: (postId: string) => void + onMarkSeen: (postId: string) => void +} + +function timeAgo(dateStr: string): string { + const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000) + if (seconds < 60) return 'just now' + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h` + const days = Math.floor(hours / 24) + return `${days}d` +} + +function formatCount(n: number | null | undefined): string { + if (n == null) return '—' + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` + return String(n) +} + +/** Extract title from content (format: title
description). */ +function extractTitle(content: string): string { + const match = content.match(/(.*?)<\/b>/) + return match ? match[1] : content.replace(/<[^>]+>/g, '').slice(0, 100) +} + +/** Extract description from content (after
). */ +function extractDescription(content: string): string { + const idx = content.indexOf('
') + if (idx === -1) return '' + return content + .slice(idx + 5) + .replace(/<[^>]+>/g, '') + .trim() +} + +export default function YouTubePostCard({ post, onBookmark, onMarkSeen }: YouTubePostCardProps) { + const title = extractTitle(post.content) + const description = extractDescription(post.content) + + return ( +
+
+ {/* Thumbnail */} + + {post.thumbnail_url ? ( + {title} + ) : ( +
+ +
+ )} + {/* Duration overlay */} + {post.duration && ( + + {post.duration} + + )} + {/* Hover play icon */} +
+ +
+
+ + {/* Metadata */} +
+ {/* Title */} + + {title} + + + {/* Channel + time */} +
+ {post.channel_title && ( + + {post.channel_title} + + )} + + + {timeAgo(post.published_at)} + +
+ + {/* Stats row */} +
+ {post.view_count != null && ( + + + {formatCount(post.view_count)} views + + )} + {post.like_count != null && ( + + + {formatCount(post.like_count)} + + )} +
+ + {/* Description snippet */} + {description && ( +

+ {description} +

+ )} + + {/* Matched interests */} + {post.matched_interests.length > 0 && ( +
+ {post.matched_interests.map((name) => ( + + {name} + + ))} +
+ )} + + {/* Relevance + actions */} +
+
+ {/* Relevance score pill */} + {post.relevance_score > 0 && ( + + {post.relevance_score.toFixed(1)} + + )} + + {/* Bookmark */} + + + {/* Mark seen */} + {!post.seen && ( + + )} +
+ + {/* Link to YouTube */} + + + Watch + +
+
+
+
+ ) +} diff --git a/ushadow/frontend/src/components/kubernetes/AddServiceDNSModal.tsx b/ushadow/frontend/src/components/kubernetes/AddServiceDNSModal.tsx new file mode 100644 index 00000000..cdf2c19e --- /dev/null +++ b/ushadow/frontend/src/components/kubernetes/AddServiceDNSModal.tsx @@ -0,0 +1,292 @@ +import { useState } from 'react' +import { Check, AlertCircle, Loader, Plus, X } from 'lucide-react' +import Modal from '../Modal' +import { kubernetesApi } from '../../services/api' + +interface AddServiceDNSModalProps { + isOpen: boolean + onClose: () => void + clusterId: string + domain: string + onSuccess: () => void +} + +export default function AddServiceDNSModal({ + isOpen, + onClose, + clusterId, + domain, + onSuccess +}: AddServiceDNSModalProps) { + const [serviceName, setServiceName] = useState('') + const [namespace, setNamespace] = useState('default') + const [shortnames, setShortnames] = useState(['']) + const [useIngress, setUseIngress] = useState(true) + const [enableTls, setEnableTls] = useState(true) + const [servicePort, setServicePort] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + + const handleAddShortname = () => { + setShortnames([...shortnames, '']) + } + + const handleRemoveShortname = (index: number) => { + setShortnames(shortnames.filter((_, i) => i !== index)) + } + + const handleShortnameChange = (index: number, value: string) => { + const newShortnames = [...shortnames] + newShortnames[index] = value + setShortnames(newShortnames) + } + + const handleAdd = async () => { + // Validation + if (!serviceName.trim()) { + setError('Service name is required') + return + } + + const validShortnames = shortnames.filter(s => s.trim()) + if (validShortnames.length === 0) { + setError('At least one shortname is required') + return + } + + if (!useIngress && !servicePort) { + setError('Service port is required when not using Ingress') + return + } + + setLoading(true) + setError(null) + + try { + await kubernetesApi.addServiceDns(clusterId, domain, { + service_name: serviceName.trim(), + namespace: namespace.trim(), + shortnames: validShortnames, + use_ingress: useIngress, + enable_tls: enableTls, + service_port: servicePort ? parseInt(servicePort) : undefined + }) + + setSuccess(true) + setTimeout(() => { + onSuccess() + handleClose() + }, 2000) + } catch (err: any) { + setError(err.response?.data?.detail || err.message || 'Failed to add service DNS') + } finally { + setLoading(false) + } + } + + const handleClose = () => { + setServiceName('') + setNamespace('default') + setShortnames(['']) + setUseIngress(true) + setEnableTls(true) + setServicePort('') + setError(null) + setSuccess(false) + onClose() + } + + return ( + + {success ? ( +
+
+ +
+

Service Added!

+

+ DNS entry and Ingress created successfully. + {enableTls && ' TLS certificate will be issued automatically.'} +

+
+ ) : ( +
+
+
+ + setServiceName(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="ushadow-frontend" + disabled={loading} + data-testid="add-service-name-input" + /> +
+ +
+ + setNamespace(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="default" + disabled={loading} + data-testid="add-service-namespace-input" + /> +
+
+ +
+ +
+ {shortnames.map((shortname, index) => ( +
+ handleShortnameChange(index, e.target.value)} + className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder={index === 0 ? 'ushadow' : 'app'} + disabled={loading} + data-testid={`add-service-shortname-${index}`} + /> + {shortnames.length > 1 && ( + + )} +
+ ))} + +
+

+ First name will be used as FQDN: {shortnames[0] || 'name'}.{domain} +

+
+ +
+ setUseIngress(e.target.checked)} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + disabled={loading} + data-testid="add-service-use-ingress-checkbox" + /> + +
+ + {!useIngress && ( +
+ + setServicePort(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="8000" + disabled={loading} + data-testid="add-service-port-input" + /> +
+ )} + +
+ setEnableTls(e.target.checked)} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + disabled={loading} + data-testid="add-service-enable-tls-checkbox" + /> + +
+ + {error && ( +
+ +

{error}

+
+ )} + +
+

This will create:

+
    +
  • DNS mapping in CoreDNS
  • +
  • Ingress resource for HTTP routing
  • + {enableTls &&
  • TLS certificate (auto-issued)
  • } +
+
+ +
+ + +
+
+ )} +
+ ) +} diff --git a/ushadow/frontend/src/components/kubernetes/ClusterNodeList.tsx b/ushadow/frontend/src/components/kubernetes/ClusterNodeList.tsx new file mode 100644 index 00000000..126ac2f1 --- /dev/null +++ b/ushadow/frontend/src/components/kubernetes/ClusterNodeList.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react' +import { ChevronDown, ChevronRight, Server, RefreshCw } from 'lucide-react' +import { kubernetesApi, KubernetesNode } from '../../services/api' + +interface ClusterNodeListProps { + clusterId: string + clusterStatus: string + nodeCount?: number +} + +function formatMemory(memStr?: string): string { + if (!memStr) return '?' + const ki = parseInt(memStr.replace('Ki', '')) + if (!isNaN(ki)) return `${(ki / 1024 / 1024).toFixed(1)} Gi` + if (memStr.endsWith('Gi')) return memStr + return memStr +} + +function GpuBadge({ node }: { node: KubernetesNode }) { + const nvidia = node.gpu_capacity_nvidia + const amd = node.gpu_capacity_amd + if (!nvidia && !amd) return null + + const parts: string[] = [] + if (nvidia) parts.push(`${nvidia}x NVIDIA`) + if (amd) parts.push(`${amd}x AMD`) + + return ( + + {parts.join(', ')} GPU + + ) +} + +function K8sNodeCard({ node }: { node: KubernetesNode }) { + return ( +
+
+
+ + + {node.name} + +
+
+ + + {node.status} + +
+
+ +
+ {node.roles.length > 0 && ( +
+ Roles: + {node.roles.join(', ')} +
+ )} +
+ CPU: + {node.cpu_capacity || '?'} +
+
+ Mem: + {formatMemory(node.memory_capacity)} +
+ {node.kubelet_version && ( +
+ Kubelet: + {node.kubelet_version} +
+ )} + {node.os_image && ( +
+ OS: + {node.os_image} +
+ )} +
+
+ ) +} + +export default function ClusterNodeList({ clusterId, clusterStatus, nodeCount }: ClusterNodeListProps) { + const [expanded, setExpanded] = useState(false) + const [nodes, setNodes] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [loaded, setLoaded] = useState(false) + + const handleToggle = async () => { + if (!expanded && !loaded) { + setLoading(true) + setError(null) + try { + const response = await kubernetesApi.listNodes(clusterId) + setNodes(response.data) + setLoaded(true) + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to load nodes') + } finally { + setLoading(false) + } + } + setExpanded(!expanded) + } + + if (clusterStatus !== 'connected') return null + + return ( +
+ + + {expanded && ( +
+ {error && ( +

{error}

+ )} + {!error && nodes.map((node) => ( + + ))} + {!error && loaded && nodes.length === 0 && ( +

No nodes found

+ )} +
+ )} +
+ ) +} diff --git a/ushadow/frontend/src/components/kubernetes/DNSManagementPanel.tsx b/ushadow/frontend/src/components/kubernetes/DNSManagementPanel.tsx new file mode 100644 index 00000000..b6974554 --- /dev/null +++ b/ushadow/frontend/src/components/kubernetes/DNSManagementPanel.tsx @@ -0,0 +1,298 @@ +import { useState, useEffect } from 'react' +import { Globe, Plus, Trash2, Shield, CheckCircle, XCircle, AlertCircle, RefreshCw, Loader } from 'lucide-react' +import { kubernetesApi, DNSStatus, type CertificateStatus } from '../../services/api' +import DNSSetupModal from './DNSSetupModal' +import AddServiceDNSModal from './AddServiceDNSModal' + +interface DNSManagementPanelProps { + clusterId: string + clusterName: string +} + +export default function DNSManagementPanel({ clusterId, clusterName }: DNSManagementPanelProps) { + const [dnsStatus, setDnsStatus] = useState(null) + const [certificates, setCertificates] = useState([]) + const [loading, setLoading] = useState(true) + const [showSetupModal, setShowSetupModal] = useState(false) + const [showAddServiceModal, setShowAddServiceModal] = useState(false) + const [removingService, setRemovingService] = useState(null) + + useEffect(() => { + loadDnsStatus() + }, [clusterId]) + + const loadDnsStatus = async () => { + setLoading(true) + try { + // Try to get status with domain if we have one + const storedDomain = localStorage.getItem(`dns-domain-${clusterId}`) + const status = await kubernetesApi.getDnsStatus(clusterId, storedDomain || undefined) + setDnsStatus(status) + + // If configured, load certificates + if (status.configured && status.cert_manager_installed) { + const certsData = await kubernetesApi.listCertificates(clusterId) + setCertificates(certsData.certificates) + } + } catch (err: any) { + console.error('Failed to load DNS status:', err) + } finally { + setLoading(false) + } + } + + const handleSetupSuccess = () => { + loadDnsStatus() + } + + const handleAddServiceSuccess = () => { + loadDnsStatus() + } + + const handleRemoveService = async (serviceName: string, namespace: string) => { + if (!dnsStatus?.domain) return + + if (!confirm(`Remove DNS for ${serviceName}? This will delete the DNS entry and Ingress.`)) { + return + } + + setRemovingService(serviceName) + try { + await kubernetesApi.removeServiceDns(clusterId, serviceName, dnsStatus.domain, namespace) + loadDnsStatus() + } catch (err: any) { + alert(err.response?.data?.detail || err.message || 'Failed to remove DNS') + } finally { + setRemovingService(null) + } + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (!dnsStatus?.configured) { + return ( +
+
+ +
+

DNS Not Configured

+

+ Setup custom DNS to access services via short, memorable names with automatic TLS certificates. +

+ + + setShowSetupModal(false)} + clusterId={clusterId} + clusterName={clusterName} + onSuccess={handleSetupSuccess} + /> +
+ ) + } + + return ( +
+ {/* DNS Status Header */} +
+
+
+

+ + DNS Configuration +

+

+ Domain: {dnsStatus.domain} +

+
+ +
+ +
+
+ CoreDNS: + {dnsStatus.coredns_ip || 'N/A'} +
+
+ Ingress: + {dnsStatus.ingress_ip || 'N/A'} +
+
+ Services: + {dnsStatus.total_services} +
+
+ cert-manager: + {dnsStatus.cert_manager_installed ? ( + + + Installed + + ) : ( + + + Not installed + + )} +
+
+
+ + {/* Services List */} +
+
+
+

Services with DNS

+ +
+
+ + {dnsStatus.mappings.length === 0 ? ( +
+

No services added yet. Click "Add Service" to get started.

+
+ ) : ( +
+ {dnsStatus.mappings.map((mapping) => { + const cert = certificates.find(c => + c.dns_names.some(dn => dn === mapping.fqdn || mapping.shortnames.includes(dn)) + ) + + return ( +
+
+
+
+
{mapping.fqdn}
+ {mapping.has_tls && ( +
+ {cert?.ready ? ( + + + TLS Ready + + ) : ( + + + TLS Pending + + )} +
+ )} +
+ +
+ {mapping.ip} + {mapping.shortnames.length > 0 && ( + + Aliases: {mapping.shortnames.map(n => ( + + {n} + + ))} + + )} +
+ + {cert && ( +
+ Expires: {cert.not_after ? new Date(cert.not_after).toLocaleDateString() : 'Unknown'} +
+ )} +
+ + +
+
+ ) + })} +
+ )} +
+ + {/* Certificates */} + {dnsStatus.cert_manager_installed && certificates.length > 0 && ( +
+

TLS Certificates

+
+ {certificates.map((cert) => ( +
+
+
+ {cert.name} + {cert.ready ? ( + + + Ready + + ) : ( + + + Pending + + )} +
+
+ {cert.dns_names.join(', ')} +
+
+
+ {cert.not_after && `Expires ${new Date(cert.not_after).toLocaleDateString()}`} +
+
+ ))} +
+
+ )} + + setShowAddServiceModal(false)} + clusterId={clusterId} + domain={dnsStatus.domain || ''} + onSuccess={handleAddServiceSuccess} + /> +
+ ) +} diff --git a/ushadow/frontend/src/components/kubernetes/DNSSetupModal.tsx b/ushadow/frontend/src/components/kubernetes/DNSSetupModal.tsx new file mode 100644 index 00000000..0e580b62 --- /dev/null +++ b/ushadow/frontend/src/components/kubernetes/DNSSetupModal.tsx @@ -0,0 +1,196 @@ +import { useState } from 'react' +import { Check, AlertCircle, Loader } from 'lucide-react' +import Modal from '../Modal' +import { kubernetesApi } from '../../services/api' + +interface DNSSetupModalProps { + isOpen: boolean + onClose: () => void + clusterId: string + clusterName: string + onSuccess: () => void +} + +export default function DNSSetupModal({ + isOpen, + onClose, + clusterId, + clusterName, + onSuccess +}: DNSSetupModalProps) { + const [domain, setDomain] = useState('') + const [acmeEmail, setAcmeEmail] = useState('') + const [installCertManager, setInstallCertManager] = useState(true) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + + const handleSetup = async () => { + if (!domain.trim()) { + setError('Domain is required') + return + } + + if (installCertManager && !acmeEmail.trim()) { + setError('Email is required for Let\'s Encrypt certificates') + return + } + + setLoading(true) + setError(null) + + try { + await kubernetesApi.setupDns(clusterId, { + domain: domain.trim(), + acme_email: acmeEmail.trim() || undefined, + install_cert_manager: installCertManager + }) + + setSuccess(true) + setTimeout(() => { + onSuccess() + handleClose() + }, 2000) + } catch (err: any) { + setError(err.response?.data?.detail || err.message || 'Failed to setup DNS') + } finally { + setLoading(false) + } + } + + const handleClose = () => { + setDomain('') + setAcmeEmail('') + setInstallCertManager(true) + setError(null) + setSuccess(false) + onClose() + } + + return ( + + {success ? ( +
+
+ +
+

DNS Setup Complete!

+

+ You can now add services to DNS with custom domain names and TLS certificates. +

+
+ ) : ( +
+

+ Setup custom DNS for your Kubernetes services with automatic TLS certificates via Let's Encrypt. +

+ +
+ + setDomain(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="e.g., chakra, mycompany, dev" + disabled={loading} + data-testid="dns-setup-domain-input" + /> +

+ Services will be accessible as: servicename.{domain || 'domain'} +

+
+ +
+ setInstallCertManager(e.target.checked)} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + disabled={loading} + data-testid="dns-setup-cert-manager-checkbox" + /> + +
+ + {installCertManager && ( +
+ + setAcmeEmail(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="admin@example.com" + disabled={loading} + data-testid="dns-setup-email-input" + /> +

+ Used for certificate expiration notifications +

+
+ )} + + {error && ( +
+ +

{error}

+
+ )} + +
+

What this does:

+
    +
  • Installs cert-manager (if selected)
  • +
  • Creates Let's Encrypt certificate issuer
  • +
  • Configures CoreDNS for custom DNS
  • +
  • Enables automatic TLS certificates
  • +
+
+ +
+ + +
+
+ )} +
+ ) +} diff --git a/ushadow/frontend/src/components/layout/Layout.tsx b/ushadow/frontend/src/components/layout/Layout.tsx index eb343720..c3054a50 100644 --- a/ushadow/frontend/src/components/layout/Layout.tsx +++ b/ushadow/frontend/src/components/layout/Layout.tsx @@ -3,12 +3,12 @@ import React, { useState, useRef, useEffect } from 'react' import { Layers, MessageSquare, Plug, Bot, Workflow, Server, Settings, LogOut, Sun, Moon, Users, Search, Bell, User, ChevronDown, Brain, Home, QrCode, Calendar, Radio } from 'lucide-react' import { LayoutDashboard, Network, Flag, FlaskConical, Cloud, Mic, MicOff, Loader2, Sparkles, Zap, Archive } from 'lucide-react' import { useAuth } from '../../contexts/AuthContext' +import { useCasdoorAuth } from '../../contexts/CasdoorAuthContext' import { useTheme } from '../../contexts/ThemeContext' import { useFeatureFlags } from '../../contexts/FeatureFlagsContext' import { useWizard } from '../../contexts/WizardContext' import { useChronicle } from '../../contexts/ChronicleContext' import { useMobileQrCode } from '../../hooks/useQrCode' -import { svcConfigsApi } from '../../services/api' import FeatureFlagsDrawer from './FeatureFlagsDrawer' import { StatusBadge, type BadgeVariant } from '../StatusBadge' import Modal from '../Modal' @@ -28,7 +28,12 @@ interface NavigationItem { export default function Layout() { const location = useLocation() const navigate = useNavigate() - const { user, logout, isAdmin } = useAuth() + const { user: legacyUser, logout: legacyLogout, isAdmin: legacyIsAdmin } = useAuth() + const { isAuthenticated: kcAuthenticated, user: kcUser, logout: kcLogout } = useCasdoorAuth() + + // Use Casdoor user if authenticated via Casdoor, otherwise use legacy user + const user = kcAuthenticated && kcUser ? kcUser : legacyUser + const isAdmin = kcAuthenticated && kcUser ? kcUser.is_superuser : legacyIsAdmin const { isDark, toggleTheme } = useTheme() const { isEnabled, flags } = useFeatureFlags() const { getSetupLabel, isFirstTimeUser } = useWizard() @@ -37,11 +42,22 @@ export default function Layout() { const [searchQuery, setSearchQuery] = useState('') const [featureFlagsDrawerOpen, setFeatureFlagsDrawerOpen] = useState(false) const userMenuRef = useRef(null) - const [isDesktopMicWired, setIsDesktopMicWired] = useState(false) // QR code hook const { qrData, loading: loadingQrCode, showModal: showQrModal, fetchQrCode, closeModal } = useMobileQrCode() + // Unified logout handler that works for both auth methods + const handleLogout = () => { + if (kcAuthenticated) { + // User is authenticated via Casdoor + kcLogout() + } else { + // User is authenticated via legacy JWT - clear localStorage only + legacyLogout() + navigate('/login') + } + } + // Get dynamic wizard label (includes path, label, level, and icon) const wizardLabel = getSetupLabel() // Helper to check if recording is in a processing state @@ -50,6 +66,20 @@ export default function Layout() { // Redirect first-time users to wizard ONLY if they just came from login/register // This prevents redirect loops when accessing the app directly useEffect(() => { + console.log('[LAYOUT] Wizard check:', { + kcAuthenticated, + pathname: location.pathname, + locationState: location.state, + isFirstTime: isFirstTimeUser(), + }) + + // Skip wizard redirect for Casdoor users - they're already authenticated via SSO + // and don't need the setup wizard + if (kcAuthenticated) { + console.log('[LAYOUT] ✅ Skipping wizard redirect - Casdoor user') + return + } + // Check sessionStorage for registration hard-reload case (cleared after reading) const sessionFromAuth = sessionStorage.getItem('fromAuth') === 'true' if (sessionFromAuth) { @@ -61,10 +91,13 @@ export default function Layout() { sessionFromAuth if (isFirstTimeUser() && fromAuth && !location.pathname.startsWith('/wizard')) { + console.log('[LAYOUT] 🔄 Redirecting first-time user to wizard') const { path } = getSetupLabel() navigate(path, { replace: true }) + } else { + console.log('[LAYOUT] ✅ No wizard redirect needed') } - }, [location, isFirstTimeUser, getSetupLabel, navigate]) + }, [location, isFirstTimeUser, getSetupLabel, navigate, kcAuthenticated]) // Close dropdown when clicking outside useEffect(() => { @@ -77,45 +110,25 @@ export default function Layout() { return () => document.removeEventListener('mousedown', handleClickOutside) }, []) - // Check if desktop-mic (or any audio_input provider) is wired - useEffect(() => { - const checkAudioWiring = async () => { - try { - const wiringRes = await svcConfigsApi.getWiring() - // Check if any audio_input provider is wired - const hasAudioWiring = wiringRes.data.some( - (w: any) => w.source_capability === 'audio_input' || - w.source_config_id?.includes('desktop-mic') || - w.source_config_id?.includes('mobile-app') - ) - setIsDesktopMicWired(hasAudioWiring) - } catch (err) { - // Silently fail - user might not be logged in yet - } - } - checkAudioWiring() - // Re-check periodically (every 30 seconds) - const interval = setInterval(checkAudioWiring, 30000) - return () => clearInterval(interval) - }, []) - // Define navigation items with optional feature flag requirements const allNavigationItems: NavigationItem[] = [ // Separator after wizard section { path: '/', label: 'Dashboard', icon: LayoutDashboard, separator: true }, { path: '/chat', label: 'Chat', icon: Sparkles }, { path: '/recording', label: 'Recording', icon: Radio }, - { path: '/chronicle', label: 'Chronicle', icon: MessageSquare }, + { path: '/chronicle', label: 'Chronicle', icon: MessageSquare, featureFlag: 'chronicle' }, + { path: '/conversations', label: 'Conversations', icon: Archive }, { path: '/speaker-recognition', label: 'Speaker ID', icon: Users, badgeVariant: 'not-implemented', featureFlag: 'speaker_recognition' }, { path: '/mcp', label: 'MCP Hub', icon: Plug, featureFlag: 'mcp_hub' }, { path: '/agent-zero', label: 'Agent Zero', icon: Bot, featureFlag: 'agent_zero' }, { path: '/n8n', label: 'n8n Workflows', icon: Workflow, featureFlag: 'n8n_workflows' }, - { path: '/services', label: 'Services', icon: Server }, - { path: '/instances', label: 'Services', icon: Layers, badgeVariant: 'beta', featureFlag: 'instances_management' }, + { path: '/services', label: 'Services', icon: Server, badgeVariant: 'deprecated', featureFlag: 'legacy_services_page' }, + { path: '/instances', label: 'Services', icon: Layers, featureFlag: 'instances_management' }, ...(isEnabled('memories_page') ? [ { path: '/memories', label: 'Memories', icon: Brain }, ] : []), { path: '/timeline', label: 'Timeline', icon: Calendar, featureFlag: 'timeline' }, + { path: '/feed', label: 'Feed', icon: Radio, featureFlag: 'social_feed' }, { path: '/cluster', label: 'Cluster', icon: Network, badgeVariant: 'beta' }, { path: '/kubernetes', label: 'Kubernetes', icon: Cloud }, { path: '/settings', label: 'Settings', icon: Settings }, @@ -412,7 +425,7 @@ export default function Layout() { className="text-sm font-medium truncate" style={{ color: isDark ? 'var(--text-primary)' : '#171717' }} > - {user?.name || 'User'} + {user?.display_name || 'User'}

{ setUserMenuOpen(false) - logout() + handleLogout() }} className="w-full flex items-center space-x-3 px-4 py-2 text-sm transition-colors" style={{ color: 'var(--error-400)' }} diff --git a/ushadow/frontend/src/components/memories/MemoryCard.tsx b/ushadow/frontend/src/components/memories/MemoryCard.tsx new file mode 100644 index 00000000..97edf518 --- /dev/null +++ b/ushadow/frontend/src/components/memories/MemoryCard.tsx @@ -0,0 +1,138 @@ +/** + * MemoryCard Component + * + * Displays a memory item in a card format with category badges, source attribution, + * and click interaction. Used in conversation detail page and memory list views. + */ + +import { Brain, ExternalLink } from 'lucide-react' +import type { ConversationMemory } from '../../services/api' + +interface MemoryCardProps { + memory: ConversationMemory + onClick?: () => void + showSource?: boolean + testId?: string +} + +// Category color mapping (matching MemoryTable) +const categoryColors: Record = { + personal: 'bg-purple-500/20 text-purple-300 border-purple-500/30', + work: 'bg-blue-500/20 text-blue-300 border-blue-500/30', + health: 'bg-green-500/20 text-green-300 border-green-500/30', + finance: 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30', + travel: 'bg-orange-500/20 text-orange-300 border-orange-500/30', + education: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30', + preferences: 'bg-pink-500/20 text-pink-300 border-pink-500/30', + relationships: 'bg-red-500/20 text-red-300 border-red-500/30', +} + +// Source color mapping +const sourceColors: Record = { + openmemory: 'bg-purple-500/20 text-purple-300 border-purple-500/30', + chronicle: 'bg-blue-500/20 text-blue-300 border-blue-500/30', + mycelia: 'bg-green-500/20 text-green-300 border-green-500/30', +} + +function formatDate(dateString: string): string { + // Handle both Unix timestamp strings and ISO date strings + const timestamp = dateString.includes('T') + ? new Date(dateString).getTime() + : parseInt(dateString) * 1000 + + return new Date(timestamp).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +export function MemoryCard({ memory, onClick, showSource = true, testId }: MemoryCardProps) { + const categories = memory.metadata?.categories || [] + + return ( +

+
+ {/* Icon */} +
+ +
+ + {/* Content */} +
+ {/* Memory content */} +

+ {memory.content} +

+ + {/* Metadata row */} +
+ {/* Categories and source */} +
+ {/* Categories */} + {categories.slice(0, 3).map((category: string, idx: number) => ( + + {category} + + ))} + + {categories.length > 3 && ( + + +{categories.length - 3} + + )} + + {/* Source badge */} + {showSource && ( + + {memory.source} + + )} +
+ + {/* Created date and action hint */} +
+ + {formatDate(memory.created_at)} + + {onClick && ( + + )} +
+
+
+
+
+ ) +} diff --git a/ushadow/frontend/src/components/memories/MemoryInterestsTab.tsx b/ushadow/frontend/src/components/memories/MemoryInterestsTab.tsx new file mode 100644 index 00000000..cc3d8935 --- /dev/null +++ b/ushadow/frontend/src/components/memories/MemoryInterestsTab.tsx @@ -0,0 +1,293 @@ +/** + * MemoryInterestsTab + * + * Toggle between two interest derivation approaches: + * - "Feed-based" : existing InterestExtractor (mem0 categories + Neo4j entity types) + * - "Graph-based" : new /api/v1/graph/interests (relationship-type scoring, depth-2) + */ + +import { useState } from 'react' +import { RefreshCw, Tag, Loader2, AlertCircle, WifiOff, TriangleAlert } from 'lucide-react' +import { useFeedInterests, useRefreshFeed, useGraphInterests } from '../../hooks/useFeed' +import type { FeedInterest, GraphInterestItem, GraphUpcomingEvent } from '../../services/feedApi' + +// ─── Shared: feed-based interest card ──────────────────────────────────────── + +function FeedInterestCard({ interest }: { interest: FeedInterest }) { + const isEntity = interest.labels.includes('entity') + const entityType = interest.labels.find((l) => l !== 'entity' && l !== 'category') ?? null + const lastActive = interest.last_active + ? new Date(interest.last_active).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) + : null + + return ( +
+
+ {interest.name} +
+ {entityType && ( + {entityType} + )} + + {isEntity ? 'entity' : 'category'} + +
+
+
+ score {interest.relationship_count} + {lastActive && active {lastActive}} +
+ {interest.hashtags.length > 0 && ( +
+ {interest.hashtags.map((tag) => ( + + #{tag} + + ))} +
+ )} +
+ ) +} + +// ─── Graph-based: interest / research item card ─────────────────────────────── + +function GraphItemCard({ item, testPrefix }: { item: GraphInterestItem; testPrefix: string }) { + return ( +
+
+ {item.name} + {item.entity_type} +
+
+ score {item.score.toFixed(1)} + {item.mentions} mention{item.mentions !== 1 ? 's' : ''} + via {item.relationship} +
+ {item.all_types.length > 1 && ( +
+ {item.all_types.map((t) => ( + {t} + ))} +
+ )} +
+ ) +} + +// ─── Graph-based: upcoming event row ───────────────────────────────────────── + +function EventRow({ event }: { event: GraphUpcomingEvent }) { + const start = new Date(event.start).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + return ( +
+ {event.emoji ?? '📅'} +
+
+ {event.name} + {start} +
+

{event.memory_content}

+
+
+ ) +} + +// ─── Graph view ─────────────────────────────────────────────────────────────── + +function GraphInterestsView() { + const { data, isLoading, isFetching, error, forceRefresh } = useGraphInterests() + + return ( +
+
+

+ {isLoading ? 'Loading…' : data + ? `${data.interests.length} interests · ${data.research_topics.length} research · ${data.upcoming_events.length} events` + : ''} +

+ +
+ + {error && ( +
+ + {(error as Error).message} +
+ )} + + {data && !data.graph_available && ( +
+ + Neo4j unavailable — graph interests could not be computed. +
+ )} + + {data?.unknown_rel_types.length ? ( +
+ + Unknown relationship types (update RELATIONSHIP_CONFIG): {data.unknown_rel_types.join(', ')} +
+ ) : null} + + {isLoading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ )} + + {data && data.interests.length > 0 && ( +
+

Interests

+
+ {data.interests.map((item) => ( + + ))} +
+
+ )} + + {data && data.research_topics.length > 0 && ( +
+

Research Topics

+
+ {data.research_topics.map((item) => ( + + ))} +
+
+ )} + + {data && data.upcoming_events.length > 0 && ( +
+

Upcoming Events

+
+ {data.upcoming_events.map((event) => ( + + ))} +
+
+ )} + + {data && !isLoading && data.interests.length === 0 && data.research_topics.length === 0 && ( +
+
+ +
+

No interests found from graph relationships.

+
+ )} +
+ ) +} + +// ─── Feed view ──────────────────────────────────────────────────────────────── + +function FeedInterestsView() { + const { interests, isLoading, error } = useFeedInterests() + const { refresh, isRefreshing, lastResult } = useRefreshFeed() + + return ( +
+
+

+ {isLoading ? 'Loading…' : `${interests.length} interest${interests.length !== 1 ? 's' : ''} derived from your memories`} +

+ +
+ + {lastResult && ( +
+ Refreshed — {lastResult.interests_count} interests, {lastResult.posts_new} new posts +
+ )} + + {error && ( +
+ + {(error as Error).message} +
+ )} + + {isLoading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ )} + + {!isLoading && !error && interests.length === 0 && ( +
+
+ +
+

No interests found

+

Add more memories so the system can derive your interests.

+
+ )} + + {!isLoading && interests.length > 0 && ( +
+ {interests.map((interest) => ( + + ))} +
+ )} +
+ ) +} + +// ─── Tab root ───────────────────────────────────────────────────────────────── + +type Source = 'feed' | 'graph' + +export function MemoryInterestsTab() { + const [source, setSource] = useState('feed') + + return ( +
+ {/* Source toggle */} +
+ {(['feed', 'graph'] as const).map((s) => ( + + ))} +
+ + {source === 'feed' ? : } +
+ ) +} diff --git a/ushadow/frontend/src/components/memories/MemoryTable.tsx b/ushadow/frontend/src/components/memories/MemoryTable.tsx index 75f6be35..a2e0a440 100644 --- a/ushadow/frontend/src/components/memories/MemoryTable.tsx +++ b/ushadow/frontend/src/components/memories/MemoryTable.tsx @@ -6,6 +6,7 @@ */ import { useState } from 'react' +import { useNavigate } from 'react-router-dom' import { Edit, MoreHorizontal, @@ -46,6 +47,7 @@ interface MemoryTableProps { } export function MemoryTable({ memories, isLoading }: MemoryTableProps) { + const navigate = useNavigate() const { selectedMemoryIds, selectMemory, @@ -92,6 +94,19 @@ export function MemoryTable({ memories, isLoading }: MemoryTableProps) { openEditDialog(id, content) } + const handleRowClick = (memoryId: string, event: React.MouseEvent) => { + // Don't navigate if clicking checkbox, action menu, or interactive elements + const target = event.target as HTMLElement + if ( + target.closest('input[type="checkbox"]') || + target.closest('button') || + target.closest('[data-testid*="memory-actions"]') + ) { + return + } + navigate(`/memories/${memoryId}`) + } + if (isLoading) { return (
@@ -147,8 +162,9 @@ export function MemoryTable({ memories, isLoading }: MemoryTableProps) { handleRowClick(memory.id, e)} className={` - hover:bg-zinc-800/50 transition-colors + hover:bg-zinc-800/50 transition-colors cursor-pointer ${memory.state === 'paused' || memory.state === 'archived' ? 'opacity-60' : ''} ${isDeleting ? 'animate-pulse opacity-50' : ''} `} diff --git a/ushadow/frontend/src/components/services/DeploymentListItem.tsx b/ushadow/frontend/src/components/services/DeploymentListItem.tsx index 25b4473c..e5ca7d9f 100644 --- a/ushadow/frontend/src/components/services/DeploymentListItem.tsx +++ b/ushadow/frontend/src/components/services/DeploymentListItem.tsx @@ -2,11 +2,15 @@ * DeploymentListItem - Individual deployment card with controls */ -import { HardDrive, Pencil, PlayCircle, StopCircle, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { HardDrive, Pencil, PlayCircle, StopCircle, Trash2, Globe, ExternalLink, Loader2 } from 'lucide-react' +import Modal from '../Modal' +import FunnelRouteManager from './FunnelRouteManager' interface DeploymentListItemProps { deployment: any serviceName: string + unodeFunnelEnabled?: boolean onStop: (id: string) => void onRestart: (id: string) => void onEdit: (deployment: any) => void @@ -16,16 +20,21 @@ interface DeploymentListItemProps { export default function DeploymentListItem({ deployment, serviceName, + unodeFunnelEnabled = false, onStop, onRestart, onEdit, onRemove, }: DeploymentListItemProps) { + const [showFunnelManager, setShowFunnelManager] = useState(false) const isRunning = deployment.status === 'running' || deployment.status === 'deploying' + const isTransitioning = deployment.status === 'starting' || deployment.status === 'stopping' const statusColor = { running: 'bg-success-100 dark:bg-success-900/30 text-success-700 dark:text-success-400', deploying: 'bg-warning-100 dark:bg-warning-900/30 text-warning-700 dark:text-warning-400', + starting: 'bg-warning-100 dark:bg-warning-900/30 text-warning-700 dark:text-warning-400', + stopping: 'bg-warning-100 dark:bg-warning-900/30 text-warning-700 dark:text-warning-400', stopped: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400', failed: 'bg-error-100 dark:bg-error-900/30 text-error-700 dark:text-error-400', }[deployment.status] || 'bg-error-100 dark:bg-error-900/30 text-error-700 dark:text-error-400' @@ -37,9 +46,10 @@ export default function DeploymentListItem({

{serviceName}

+ {isTransitioning && } {deployment.status} @@ -47,7 +57,8 @@ export default function DeploymentListItem({ {isRunning ? ( )}
@@ -74,9 +90,35 @@ export default function DeploymentListItem({ :{deployment.exposed_port} )} + {deployment.public_url && ( + + + Public + + + )}
+ {/* Manage Public Access (only for funnel-enabled unodes) */} + {unodeFunnelEnabled && ( + + )} + {/* Edit */}
+ + {/* Funnel Route Manager Modal */} + {showFunnelManager && ( + setShowFunnelManager(false)} + title="Manage Public Access" + maxWidth="lg" + testId="funnel-manager-modal" + > + + + )}
) } diff --git a/ushadow/frontend/src/components/services/DeploymentsTab.tsx b/ushadow/frontend/src/components/services/DeploymentsTab.tsx index cb937d66..58df1ede 100644 --- a/ushadow/frontend/src/components/services/DeploymentsTab.tsx +++ b/ushadow/frontend/src/components/services/DeploymentsTab.tsx @@ -5,11 +5,12 @@ import { HardDrive } from 'lucide-react' import DeploymentListItem from './DeploymentListItem' import EmptyState from './EmptyState' -import { Template } from '../../services/api' +import { Template, DeployTarget } from '../../services/api' interface DeploymentsTabProps { deployments: any[] templates: Template[] + targets?: DeployTarget[] filterCurrentEnvOnly: boolean onFilterChange: (checked: boolean) => void onStopDeployment: (id: string) => void @@ -21,6 +22,7 @@ interface DeploymentsTabProps { export default function DeploymentsTab({ deployments, templates, + targets = [], filterCurrentEnvOnly, onFilterChange, onStopDeployment, @@ -28,6 +30,16 @@ export default function DeploymentsTab({ onEditDeployment, onRemoveDeployment, }: DeploymentsTabProps) { + // Helper to check if unode is funnel-enabled + const isUnodeFunnelEnabled = (unodeHostname: string): boolean => { + const target = targets.find((t) => t.identifier === unodeHostname) + if (!target) return false + + // Check raw_metadata for unode labels + const labels = target.raw_metadata?.labels || {} + return labels.funnel === 'enabled' + } + return (
@@ -58,11 +70,14 @@ export default function DeploymentsTab({
{deployments.map((deployment) => { const template = templates.find((t) => t.id === deployment.service_id) + const unodeFunnelEnabled = isUnodeFunnelEnabled(deployment.unode_hostname) + return ( void + serviceId: string + serviceName: string +} + +export function FindAndAdoptModal({ + isOpen, + onClose, + serviceId, + serviceName, +}: FindAndAdoptModalProps) { + const queryClient = useQueryClient() + const [adoptingName, setAdoptingName] = useState(null) + + const { data: workloads = [], isLoading } = useQuery({ + queryKey: ['find-workloads', serviceId], + queryFn: () => deploymentsApi.findWorkloads(serviceId).then((r) => r.data), + enabled: isOpen, + }) + + const adopt = useMutation({ + mutationFn: (workload: DiscoveredWorkload) => { + const req: AdoptRequest = { + backend_type: workload.backend_type, + container_name: workload.name, + image: workload.image, + ports: workload.ports, + status: workload.status, + node_hostname: workload.node_hostname, + container_id: workload.container_id, + compose_project: workload.compose_project, + cluster_id: workload.cluster_id, + namespace: workload.namespace, + k8s_deployment_name: workload.k8s_deployment_name, + } + return deploymentsApi.adoptWorkload(serviceId, req) + }, + onSuccess: () => { + // Deployments are fetched as part of the 'service-configs' bundle in useServiceConfigData + queryClient.invalidateQueries({ queryKey: ['service-configs'] }) + queryClient.invalidateQueries({ queryKey: ['find-workloads', serviceId] }) + setAdoptingName(null) + }, + }) + + return ( + +
+

+ Searching Docker containers and Kubernetes clusters for workloads + matching {serviceName}. + Adopting a workload lets Ushadow proxy, restart, and configure it without + redeploying. +

+ + {isLoading && ( +
+ + Scanning Docker and Kubernetes… +
+ )} + + {!isLoading && workloads.length === 0 && ( +
+ No matching workloads found in Docker or any registered K8s cluster. +
+ )} + + {!isLoading && workloads.length > 0 && ( +
+ {workloads.map((w) => ( + { + setAdoptingName(w.name) + adopt.mutate(w) + }} + /> + ))} +
+ )} +
+
+ ) +} + +// ─── WorkloadRow ────────────────────────────────────────────────────────────── + +interface WorkloadRowProps { + workload: DiscoveredWorkload + isAdopting: boolean + onAdopt: () => void +} + +function WorkloadRow({ workload: w, isAdopting, onAdopt }: WorkloadRowProps) { + return ( +
+ {/* Left: badge + name + image */} +
+ +
+

+ {w.name} +

+

+ {w.image} +

+ {w.ports.length > 0 && ( +

+ ports: {w.ports.join(', ')} +

+ )} +
+
+ + {/* Right: status + action */} +
+ + + {w.already_adopted ? ( + + + Adopted + + ) : ( + + )} +
+
+ ) +} + +// ─── BackendBadge ───────────────────────────────────────────────────────────── + +function BackendBadge({ workload: w }: { workload: DiscoveredWorkload }) { + if (w.backend_type === 'kubernetes') { + const label = [w.cluster_name, w.namespace].filter(Boolean).join(' · ') + return ( + + + K8s{label ? ` · ${label}` : ''} + + ) + } + return ( + + + Docker{w.compose_project ? ` · ${w.compose_project}` : ''} + + ) +} diff --git a/ushadow/frontend/src/components/services/FunnelRouteManager.tsx b/ushadow/frontend/src/components/services/FunnelRouteManager.tsx new file mode 100644 index 00000000..53fb37bc --- /dev/null +++ b/ushadow/frontend/src/components/services/FunnelRouteManager.tsx @@ -0,0 +1,295 @@ +/** + * FunnelRouteManager - Manage Tailscale Funnel routes for public access + * + * Allows adding, updating, and removing public routes on funnel-enabled unodes. + * Routes are stored in service_configs.yaml for future deployments. + */ + +import { useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Globe, ExternalLink, Copy, Check } from 'lucide-react' +import { deploymentsApi } from '../../services/api' +import type { Deployment } from '../../services/api' +import Modal from '../Modal' +import ConfirmDialog from '../ConfirmDialog' + +interface FunnelRouteManagerProps { + deployment: Deployment + unodeFunnelEnabled: boolean +} + +export default function FunnelRouteManager({ deployment, unodeFunnelEnabled }: FunnelRouteManagerProps) { + const queryClient = useQueryClient() + const [isEditing, setIsEditing] = useState(false) + const [showRemoveConfirm, setShowRemoveConfirm] = useState(false) + const [route, setRoute] = useState('') + const [saveToConfig, setSaveToConfig] = useState(true) + const [copied, setCopied] = useState(false) + + // Fetch current funnel configuration + const { data: funnelConfig, isLoading } = useQuery({ + queryKey: ['funnel-config', deployment.id], + queryFn: () => deploymentsApi.getFunnelConfiguration(deployment.id), + enabled: unodeFunnelEnabled, + }) + + // Configure funnel route mutation + const configureMutation = useMutation({ + mutationFn: ({ deploymentId, route, saveToConfig }: { deploymentId: string; route: string; saveToConfig: boolean }) => + deploymentsApi.configureFunnelRoute(deploymentId, route, saveToConfig), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['funnel-config', deployment.id] }) + queryClient.invalidateQueries({ queryKey: ['deployments'] }) + setIsEditing(false) + setRoute('') + }, + }) + + // Remove funnel route mutation + const removeMutation = useMutation({ + mutationFn: ({ deploymentId, saveToConfig }: { deploymentId: string; saveToConfig: boolean }) => + deploymentsApi.removeFunnelRoute(deploymentId, saveToConfig), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['funnel-config', deployment.id] }) + queryClient.invalidateQueries({ queryKey: ['deployments'] }) + setShowRemoveConfirm(false) + }, + }) + + const handleConfigure = () => { + if (!route || !route.startsWith('/')) { + return + } + configureMutation.mutate({ + deploymentId: deployment.id, + route, + saveToConfig, + }) + } + + const handleRemove = () => { + removeMutation.mutate({ + deploymentId: deployment.id, + saveToConfig, + }) + } + + const handleCopy = async (text: string) => { + try { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (err) { + console.error('Failed to copy:', err) + } + } + + const handleStartEditing = () => { + setRoute(funnelConfig?.route || '') + setIsEditing(true) + } + + if (!unodeFunnelEnabled) { + return null + } + + if (isLoading) { + return ( +
+
+
+
+
+
+ ) + } + + const hasRoute = !!funnelConfig?.route + const publicUrl = funnelConfig?.public_url + + return ( + <> +
+
+
+ +

Public Access

+
+ {hasRoute ? ( + + Public + + ) : ( + + Private + + )} +
+ + {!hasRoute && !isEditing && ( +
+

+ This service is not publicly accessible. Only accessible within your Tailnet. +

+ +
+ )} + + {hasRoute && !isEditing && ( +
+
+
+ +
+ + {funnelConfig.route} + +
+
+ + {publicUrl && ( +
+ +
+ + {publicUrl} + + + + + +
+
+ )} +
+ +
+ + +
+
+ )} + + {isEditing && ( + setIsEditing(false)} + title={hasRoute ? 'Change Public Route' : 'Configure Public Access'} + maxWidth="md" + testId="funnel-route-modal" + > +
+
+ + setRoute(e.target.value)} + placeholder="/myservice" + pattern="^/[a-z0-9-/]+$" + className="input w-full" + data-testid="funnel-route-input" + /> +

+ Service will be accessible at: https://<hostname>{route || '/path'} +

+
+ + + +
+ + +
+ + {configureMutation.isError && ( +
+

+ {configureMutation.error instanceof Error ? configureMutation.error.message : 'Failed to configure route'} +

+
+ )} +
+
+ )} +
+ + setShowRemoveConfirm(false)} + onConfirm={handleRemove} + title="Remove Public Access?" + message="This will make the service private (Tailnet-only). The service will continue running but won't be accessible from the public internet." + variant="warning" + confirmLabel={removeMutation.isPending ? 'Removing...' : 'Remove Access'} + /> + + ) +} diff --git a/ushadow/frontend/src/components/services/PageHeader.tsx b/ushadow/frontend/src/components/services/PageHeader.tsx index 21937d32..66ab1a90 100644 --- a/ushadow/frontend/src/components/services/PageHeader.tsx +++ b/ushadow/frontend/src/components/services/PageHeader.tsx @@ -23,9 +23,6 @@ export default function PageHeader({

Services

- - BETA -

Create and manage service instances from templates diff --git a/ushadow/frontend/src/components/services/ProviderCard.tsx b/ushadow/frontend/src/components/services/ProviderCard.tsx index 6107a368..bb67fb5e 100644 --- a/ushadow/frontend/src/components/services/ProviderCard.tsx +++ b/ushadow/frontend/src/components/services/ProviderCard.tsx @@ -2,12 +2,13 @@ * ProviderCard - Individual provider card with expand/collapse and env var editing */ -import { CheckCircle, ChevronUp, Cloud, HardDrive, Loader2, Pencil, Save } from 'lucide-react' +import { AlertCircle, CheckCircle, ChevronUp, Cloud, HardDrive, Loader2, Pencil, Save } from 'lucide-react' import EnvVarEditor from '../EnvVarEditor' -import { EnvVarInfo, EnvVarConfig } from '../../services/api' +import { EnvVarInfo, EnvVarConfig, ServiceConfigSummary } from '../../services/api' interface ProviderCardProps { provider: any + instances?: ServiceConfigSummary[] isExpanded: boolean onToggleExpand: () => void envVars: EnvVarInfo[] @@ -21,6 +22,7 @@ interface ProviderCardProps { export default function ProviderCard({ provider, + instances = [], isExpanded, onToggleExpand, envVars, @@ -31,12 +33,16 @@ export default function ProviderCard({ isLoading, isSaving, }: ProviderCardProps) { + const isConfigured = provider.configured !== false + return (

@@ -62,13 +68,20 @@ export default function ProviderCard({
{provider.name}
- {provider.description && !isExpanded && ( + {!isConfigured && !isExpanded && ( +
Needs setup
+ )} + {isConfigured && provider.description && !isExpanded && (
{provider.description}
)}
- + {isConfigured ? ( + + ) : ( + + )}
+ {/* Service config instances */} + {instances.length > 0 && ( +
+ {instances.map(inst => ( + + {inst.name} + {inst.config?.model && ( + · {inst.config.model} + )} + + ))} +
+ )} + {/* Expanded Content - EnvVarEditor */} {isExpanded && (
@@ -116,6 +147,7 @@ export default function ProviderCard({ diff --git a/ushadow/frontend/src/components/services/ProvidersTab.tsx b/ushadow/frontend/src/components/services/ProvidersTab.tsx index cd1253c9..69dcf24d 100644 --- a/ushadow/frontend/src/components/services/ProvidersTab.tsx +++ b/ushadow/frontend/src/components/services/ProvidersTab.tsx @@ -2,14 +2,15 @@ * ProvidersTab - Providers tab content with grouped provider cards */ -import { Database } from 'lucide-react' +import { AlertCircle, Database } from 'lucide-react' import { useMemo } from 'react' import ProviderCard from './ProviderCard' import EmptyState from './EmptyState' -import { EnvVarInfo, EnvVarConfig, Template } from '../../services/api' +import { EnvVarInfo, EnvVarConfig, Template, ServiceConfigSummary } from '../../services/api' interface ProvidersTabProps { providers: Template[] + instances?: ServiceConfigSummary[] expandedProviderId: string | null onToggleExpand: (providerId: string) => void envVars: EnvVarInfo[] @@ -23,6 +24,7 @@ interface ProvidersTabProps { export default function ProvidersTab({ providers, + instances = [], expandedProviderId, onToggleExpand, envVars, @@ -33,21 +35,29 @@ export default function ProvidersTab({ isLoading, isSaving, }: ProvidersTabProps) { - // Group providers by capability type - const groupedProviders = useMemo(() => { - const configuredProviders = providers.filter((p) => p.configured) - const grouped = configuredProviders.reduce( + const { grouped, sortedCapabilities, needsSetupCount } = useMemo(() => { + // Provider IDs that have at least one real service config (id !== template_id means user-created) + const providerIdsWithConfigs = new Set( + instances.filter(i => i.id !== i.template_id).map(i => i.template_id) + ) + // Show configured, installed, running, or providers with user-created service configs + const relevant = providers.filter( + (p) => p.configured || p.installed || p.running || providerIdsWithConfigs.has(p.id) + ) + + const capabilityOrder = ['llm', 'transcription', 'memory', 'embedding', 'tts', 'other'] + + const grp = relevant.reduce( (acc, provider) => { const capability = provider.provides || 'other' if (!acc[capability]) acc[capability] = [] acc[capability].push(provider) return acc }, - {} as Record + {} as Record ) - const capabilityOrder = ['llm', 'transcription', 'memory', 'embedding', 'tts', 'other'] - const sortedCapabilities = Object.keys(grouped).sort((a, b) => { + const sorted = Object.keys(grp).sort((a, b) => { const aIndex = capabilityOrder.indexOf(a) const bIndex = capabilityOrder.indexOf(b) if (aIndex === -1 && bIndex === -1) return a.localeCompare(b) @@ -56,10 +66,14 @@ export default function ProvidersTab({ return aIndex - bIndex }) - return { grouped, sortedCapabilities } + return { + grouped: grp, + sortedCapabilities: sorted, + needsSetupCount: relevant.filter((p) => !p.configured).length, + } }, [providers]) - if (groupedProviders.sortedCapabilities.length === 0) { + if (sortedCapabilities.length === 0) { return ( - {groupedProviders.sortedCapabilities.map((capability) => ( + {needsSetupCount > 0 && ( +
+ + {needsSetupCount === 1 + ? '1 provider needs configuration.' + : `${needsSetupCount} providers need configuration.`} +
+ )} + {sortedCapabilities.map((capability) => (

{capability}

-
- {groupedProviders.grouped[capability].map((provider) => { +
+ {grouped[capability].map((provider) => { const isExpanded = expandedProviderId === provider.id + const providerInstances = instances.filter( + i => i.template_id === provider.id && i.id !== i.template_id + ) return ( onToggleExpand(provider.id)} envVars={isExpanded ? envVars : []} diff --git a/ushadow/frontend/src/components/services/ServiceCard.tsx b/ushadow/frontend/src/components/services/ServiceCard.tsx index 71a37b0b..6066a705 100644 --- a/ushadow/frontend/src/components/services/ServiceCard.tsx +++ b/ushadow/frontend/src/components/services/ServiceCard.tsx @@ -194,25 +194,36 @@ export function ServiceCard({ {/* Enable/Disable Toggle */} {!isEditing && ( - + {isTogglingEnabled && ( + )} - +
)} {/* Expand indicator */} diff --git a/ushadow/frontend/src/components/services/ServicesTab.tsx b/ushadow/frontend/src/components/services/ServicesTab.tsx index 5ddafba0..8a4c09c9 100644 --- a/ushadow/frontend/src/components/services/ServicesTab.tsx +++ b/ushadow/frontend/src/components/services/ServicesTab.tsx @@ -1,10 +1,13 @@ /** * ServicesTab - Services tab content with FlatServiceCard grid + * Organized into API/Workers and UI subtabs */ -import { Package } from 'lucide-react' +import { useState } from 'react' +import { Package, Server, Monitor } from 'lucide-react' import { FlatServiceCard } from '../wiring' import EmptyState from './EmptyState' +import { FindAndAdoptModal } from './FindAndAdoptModal' import { Template, ServiceConfig, Wiring, DeployTarget } from '../../services/api' interface ServicesTabProps { @@ -14,6 +17,8 @@ interface ServicesTabProps { providerTemplates: Template[] serviceStatuses: Record deployments: any[] + togglingDeployments: Set + splitServicesEnabled?: boolean // Feature flag for split services view onAddConfig: (serviceId: string) => void onWiringChange: (consumerId: string, capability: string, sourceConfigId: string) => Promise onWiringClear: (consumerId: string, capability: string) => Promise @@ -31,6 +36,8 @@ interface ServicesTabProps { onEditDeployment: (deployment: any) => void } +type ServiceSubTab = 'api' | 'ui' + export default function ServicesTab({ composeTemplates, instances, @@ -38,6 +45,8 @@ export default function ServicesTab({ providerTemplates, serviceStatuses, deployments, + togglingDeployments, + splitServicesEnabled = false, onAddConfig, onWiringChange, onWiringClear, @@ -54,6 +63,9 @@ export default function ServicesTab({ onRemoveDeployment, onEditDeployment, }: ServicesTabProps) { + const [activeSubTab, setActiveSubTab] = useState('api') + const [findingService, setFindingService] = useState<{ id: string; name: string } | null>(null) + if (composeTemplates.length === 0) { return ( +
+

+ Select providers for each service capability +

+
+ + {/* Service Cards - Masonry Layout */} +
+ {composeTemplates.map((template) => { + // Find ALL configs for this template + const templateConfigs = instances.filter((i) => i.template_id === template.id) + // Show the first config (or null if none) + const config = templateConfigs[0] || null + const consumerId = config?.id || template.id + + // Get service status from Docker + const serviceName = template.id.includes(':') ? template.id.split(':').pop()! : template.id + const status = serviceStatuses[serviceName] + + // Filter wiring for this consumer + const consumerWiring = wiring.filter((w) => w.target_config_id === consumerId) + + // Get deployments for this service + const serviceDeployments = deployments.filter((d) => d.service_id === template.id) + + return ( +
+ onAddConfig(template.id)} + onWiringChange={(capability, sourceConfigId) => + onWiringChange(consumerId, capability, sourceConfigId) + } + onWiringClear={(capability) => onWiringClear(consumerId, capability)} + onConfigCreate={onConfigCreate} + onEditConfig={onEditConfig} + onDeleteConfig={onDeleteConfig} + onUpdateConfig={onUpdateConfig} + onStart={() => onStart(template.id)} + onStop={() => onStop(template.id)} + onEdit={() => onEdit(template.id)} + onFind={() => setFindingService({ id: template.id, name: template.name })} + onDeploy={(target) => onDeploy(template.id, target)} + /> +
+ ) + })} +
+
+ ) + } + + // Separate services into UI and non-UI (API/Workers) + // UI services have "ui" or "frontend" in their name + const isUiService = (template: Template) => { + const name = template.name.toLowerCase() + return name.includes('ui') || name.includes('frontend') + } + + const uiServices = composeTemplates.filter(isUiService) + + const apiServices = composeTemplates.filter((template) => !isUiService(template)) + + // Group workers with their corresponding API services + // Workers typically have "-worker" or "-workers" in their name + const groupedApiServices = apiServices.reduce((acc, template) => { + const templateName = template.name.toLowerCase() + + // Check if this is a worker + if (templateName.includes('worker')) { + // Try to find the corresponding API service + // Remove "worker", "workers", "-worker", "-workers", "python", etc. to find the base name + const baseName = templateName + .replace(/[-_\s]?workers?[-_\s]?/gi, '') + .replace(/[-_\s]?(python|node|go|rust)[-_\s]?/gi, '') // Remove language qualifiers + .trim() + + // Find the API service that matches this base name + // Check both directions: parent contains baseName OR baseName contains parent + const apiService = apiServices.find(t => { + if (t.name.toLowerCase().includes('worker')) return false + const parentName = t.name.toLowerCase() + return parentName.includes(baseName) || baseName.includes(parentName) + }) + + if (apiService) { + // Add this worker to the API service's workers array + const existingGroup = acc.find(g => g.api.id === apiService.id) + if (existingGroup) { + existingGroup.workers.push(template) + } else { + acc.push({ api: apiService, workers: [template] }) + } + return acc + } + } + + // This is an API service (non-worker) + // Check if we already have a group for it + const existingGroup = acc.find(g => g.api.id === template.id) + if (!existingGroup) { + acc.push({ api: template, workers: [] }) + } + + return acc + }, [] as Array<{ api: Template; workers: Template[] }>) + + // Helper to get worker data in the format expected by FlatServiceCard + const getWorkerData = (workerTemplates: Template[]) => { + return workerTemplates.map((workerTemplate) => { + const workerConfigs = instances.filter((i) => i.template_id === workerTemplate.id) + const workerConfig = workerConfigs[0] || null + const workerServiceName = workerTemplate.id.includes(':') + ? workerTemplate.id.split(':').pop()! + : workerTemplate.id + const workerStatus = serviceStatuses[workerServiceName] + const workerDeployments = deployments.filter((d) => d.service_id === workerTemplate.id) + + return { + template: workerTemplate, + config: workerConfig, + status: workerStatus?.status || 'stopped', + deployments: workerDeployments, + } + }) + } + + const renderServiceCard = (template: Template, workerTemplates: Template[] = []) => { + // Find ALL configs for this template + const templateConfigs = instances.filter((i) => i.template_id === template.id) + // Show the first config (or null if none) + const config = templateConfigs[0] || null + const consumerId = config?.id || template.id + + // Get service status from Docker + const serviceName = template.id.includes(':') ? template.id.split(':').pop()! : template.id + const status = serviceStatuses[serviceName] + + // Prepare worker data first so we can include their wiring + const workers = getWorkerData(workerTemplates) + + // Get all relevant consumer IDs (main service + all workers) + const workerConsumerIds = workers.map((w) => w.config?.id || w.template.id) + const allConsumerIds = [consumerId, ...workerConsumerIds] + + // Filter wiring for this consumer and all workers + const consumerWiring = wiring.filter((w) => allConsumerIds.includes(w.target_config_id)) + + // Get deployments for this service + const serviceDeployments = deployments.filter((d) => d.service_id === template.id) + + return ( + onAddConfig(template.id)} + onWiringChange={(capability, sourceConfigId) => + onWiringChange(consumerId, capability, sourceConfigId) + } + onWiringClear={(capability) => onWiringClear(consumerId, capability)} + onConfigCreate={onConfigCreate} + onEditConfig={onEditConfig} + onDeleteConfig={onDeleteConfig} + onUpdateConfig={onUpdateConfig} + onStart={() => onStart(template.id)} + onStop={() => onStop(template.id)} + onEdit={() => onEdit(template.id)} + onFind={() => setFindingService({ id: template.id, name: template.name })} + onDeploy={(target) => onDeploy(template.id, target)} + workers={workers} + onStartWorker={onStart} + onStopWorker={onStop} + onEditWorker={onEdit} + onDeployWorker={(templateId, target) => onDeploy(templateId, target)} + onWorkerWiringChange={(workerConfigId, capability, sourceConfigId) => + onWiringChange(workerConfigId, capability, sourceConfigId) + } + onWorkerWiringClear={(workerConfigId, capability) => + onWiringClear(workerConfigId, capability) + } + /> + ) + } + + const currentServices = activeSubTab === 'api' ? groupedApiServices : uiServices + return (
+ {/* Sub-tab navigation */} +
+ + +
+

- Select providers for each service capability + {activeSubTab === 'api' + ? 'API services and their workers' + : 'User interface services'}

- {/* Service Cards Grid */} -
- {composeTemplates.map((template) => { - // Find ALL configs for this template - const templateConfigs = instances.filter((i) => i.template_id === template.id) - // Show the first config (or null if none) - const config = templateConfigs[0] || null - const consumerId = config?.id || template.id - - // Get service status from Docker - const serviceName = template.id.includes(':') ? template.id.split(':').pop()! : template.id - const status = serviceStatuses[serviceName] - - // Filter wiring for this consumer - const consumerWiring = wiring.filter((w) => w.target_config_id === consumerId) - - // Get deployments for this service - const serviceDeployments = deployments.filter((d) => d.service_id === template.id) - - return ( - onAddConfig(template.id)} - onWiringChange={(capability, sourceConfigId) => - onWiringChange(consumerId, capability, sourceConfigId) - } - onWiringClear={(capability) => onWiringClear(consumerId, capability)} - onConfigCreate={onConfigCreate} - onEditConfig={onEditConfig} - onDeleteConfig={onDeleteConfig} - onUpdateConfig={onUpdateConfig} - onStart={() => onStart(template.id)} - onStop={() => onStop(template.id)} - onEdit={() => onEdit(template.id)} - onDeploy={(target) => onDeploy(template.id, target)} - /> - ) - })} -
+ {/* Service Cards - Masonry Layout */} + {activeSubTab === 'api' ? ( +
+ {groupedApiServices.map(({ api, workers }) => ( +
+ {/* API Service Card with workers embedded */} + {renderServiceCard(api, workers)} +
+ ))} +
+ ) : ( +
+ {uiServices.map((template) => ( +
+ {renderServiceCard(template)} +
+ ))} +
+ )} + + {currentServices.length === 0 && ( + + )} + + {findingService && ( + setFindingService(null)} + serviceId={findingService.id} + serviceName={findingService.name} + /> + )}
) } diff --git a/ushadow/frontend/src/components/settings/NetworkTab.tsx b/ushadow/frontend/src/components/settings/NetworkTab.tsx new file mode 100644 index 00000000..a6a01fe5 --- /dev/null +++ b/ushadow/frontend/src/components/settings/NetworkTab.tsx @@ -0,0 +1,196 @@ +/** + * Network Settings Tab + * + * Controls for Tailscale Funnel and network configuration + */ + +import { useState, useEffect } from 'react' +import { Globe, Wifi, AlertCircle, CheckCircle, ExternalLink, Lock } from 'lucide-react' +import { api } from '../../services/api' + +interface FunnelStatus { + enabled: boolean + port: number | null + public_url: string | null + error?: string +} + +export function NetworkTab() { + const [funnelStatus, setFunnelStatus] = useState(null) + const [loading, setLoading] = useState(true) + const [toggling, setToggling] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + loadFunnelStatus() + }, []) + + const loadFunnelStatus = async () => { + setLoading(true) + setError(null) + try { + const response = await api.get('/api/tailscale/funnel/status') + setFunnelStatus(response.data) + } catch (err: any) { + console.error('Failed to load funnel status:', err) + setError(err?.response?.data?.detail || 'Failed to load Tailscale Funnel status') + } finally { + setLoading(false) + } + } + + const handleToggleFunnel = async () => { + setToggling(true) + setError(null) + try { + if (funnelStatus?.enabled) { + // Disable funnel + await api.post('/api/tailscale/funnel/disable') + } else { + // Enable funnel + await api.post('/api/tailscale/funnel/enable') + } + // Reload status + await loadFunnelStatus() + } catch (err: any) { + console.error('Failed to toggle funnel:', err) + setError(err?.response?.data?.detail || 'Failed to toggle Tailscale Funnel') + } finally { + setToggling(false) + } + } + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+ {/* Tailscale Funnel Section */} +
+
+
+ +
+

+ Tailscale Funnel +

+

+ Expose ushadow to the public internet for remote access +

+
+
+ + {funnelStatus && ( +
+ {funnelStatus.enabled ? ( + <> + + Active + + ) : ( + <> + + Tailnet Only + + )} +
+ )} +
+ + {error && ( +
+
+ + {error} +
+
+ )} + + {/* Funnel Status Details */} + {funnelStatus?.enabled && funnelStatus.public_url && ( +
+
+ +
+

+ Public URL +

+ + {funnelStatus.public_url} + + +

+ This URL is accessible from anywhere on the internet +

+
+
+
+ )} + + {/* Information Box */} +
+
+ +
+

+ Tailnet Only (Default): Only accessible via Tailscale VPN. + Most secure, recommended for personal use. +

+

+ Funnel Enabled: Publicly accessible on the internet. + Anyone with the URL can access (requires authentication). Use for sharing with people outside your Tailnet. +

+
+
+
+ + {/* Toggle Button */} + + + {/* Warning for public access */} + {!funnelStatus?.enabled && ( +
+
+ +

+ Enabling Funnel will make your ushadow instance accessible to anyone on the internet. + Ensure authentication is properly configured before enabling. +

+
+
+ )} +
+
+ ) +} diff --git a/ushadow/frontend/src/components/settings/index.ts b/ushadow/frontend/src/components/settings/index.ts index acb78117..f15b4d63 100644 --- a/ushadow/frontend/src/components/settings/index.ts +++ b/ushadow/frontend/src/components/settings/index.ts @@ -25,3 +25,4 @@ export { SettingField, type SettingFieldProps, type SettingType, type SelectOpti export { SettingsSection, type SettingsSectionProps } from './SettingsSection' export { RequiredFieldsSection } from './RequiredFieldsSection' export { UnifiedServiceSettings } from './UnifiedServiceSettings' +export { NetworkTab } from './NetworkTab' diff --git a/ushadow/frontend/src/components/wiring/CapabilitySlot.tsx b/ushadow/frontend/src/components/wiring/CapabilitySlot.tsx index dec2b858..2863594a 100644 --- a/ushadow/frontend/src/components/wiring/CapabilitySlot.tsx +++ b/ushadow/frontend/src/components/wiring/CapabilitySlot.tsx @@ -224,7 +224,25 @@ function CapabilitySlotDropdown({ export function CapabilitySlot(props: CapabilitySlotProps) { if (props.mode === 'dropdown') { - return + const { onSelect, selectedOption, options, ...rest } = props + return ( +
{ + console.log(`[CapabilitySlot] ${props.consumerId}/${props.capability} opened`, { + selected: selectedOption ? { id: selectedOption.id, name: selectedOption.name, configSummary: selectedOption.configSummary, templateId: selectedOption.templateId } : null, + options: Object.fromEntries(Object.entries(options).map(([group, items]) => [group, items.map(o => o.id)])), + }) + }}> + { + console.log(`[CapabilitySlot] ${props.consumerId}/${props.capability} → selected`, { id: option.id, name: option.name, configSummary: option.configSummary, templateId: option.templateId }) + onSelect(option) + }} + /> +
+ ) } // Default to legacy mode for backwards compatibility diff --git a/ushadow/frontend/src/components/wiring/FlatServiceCard.tsx b/ushadow/frontend/src/components/wiring/FlatServiceCard.tsx index 33d62523..b10a8a94 100644 --- a/ushadow/frontend/src/components/wiring/FlatServiceCard.tsx +++ b/ushadow/frontend/src/components/wiring/FlatServiceCard.tsx @@ -20,9 +20,9 @@ import { Loader2, PlayCircle, StopCircle, - Plus, Pencil, Rocket, + Search, X, Trash2, Save, @@ -32,7 +32,6 @@ import { Monitor, } from 'lucide-react' import { CapabilitySlot } from './CapabilitySlot' -import { StatusIndicator } from './StatusIndicator' import { SettingField } from '../settings/SettingField' import { useProviderConfigs, type ProviderOption, type UseProviderConfigsOptions } from '../../hooks/useProviderConfigs' import type { Template, ServiceConfigSummary, Wiring } from '../../services/api' @@ -66,6 +65,8 @@ export interface FlatServiceCardProps { onStop?: () => Promise /** Called to edit settings */ onEdit?: () => void + /** Called to find existing instances of this service in Docker/K8s */ + onFind?: () => void /** Called to add a new config variant */ onAddConfig?: () => void /** Called to deploy the service */ @@ -74,10 +75,14 @@ export interface FlatServiceCardProps { providerTemplates: Template[] /** Pre-fetched configs to avoid duplicate API calls */ initialConfigs?: ServiceConfigSummary[] + /** All service configs (provider configs included) for capability dropdown lookups */ + allConfigs?: ServiceConfigSummary[] /** Number of configured instances */ instanceCount?: number /** Active deployments for this service */ deployments?: any[] + /** Set of deployment IDs currently being toggled */ + togglingDeployments?: Set /** Called to stop a deployment */ onStopDeployment?: (deploymentId: string) => Promise /** Called to restart a deployment */ @@ -86,6 +91,25 @@ export interface FlatServiceCardProps { onRemoveDeployment?: (deploymentId: string, serviceName: string) => Promise /** Called to edit a deployment */ onEditDeployment?: (deployment: any) => Promise + /** Worker services associated with this service */ + workers?: Array<{ + template: Template + config: ServiceConfigSummary | null + status: string + deployments: any[] + }> + /** Called to start a worker */ + onStartWorker?: (templateId: string) => Promise + /** Called to stop a worker */ + onStopWorker?: (templateId: string) => Promise + /** Called to edit a worker's settings */ + onEditWorker?: (templateId: string) => void + /** Called to deploy a worker */ + onDeployWorker?: (templateId: string, target: { type: 'local' | 'remote' | 'kubernetes' }) => void + /** Called when a provider is selected for a worker capability */ + onWorkerWiringChange?: (workerConfigId: string, capability: string, sourceConfigId: string) => Promise + /** Called when worker wiring is cleared */ + onWorkerWiringClear?: (workerConfigId: string, capability: string) => Promise } // ============================================================================ @@ -405,28 +429,47 @@ export function FlatServiceCard({ onStart, onStop, onEdit, + onFind, onAddConfig, onDeploy, providerTemplates, initialConfigs, + allConfigs, instanceCount = 0, deployments = [], + togglingDeployments = new Set(), onStopDeployment, onRestartDeployment, onRemoveDeployment, onEditDeployment, + workers = [], + onStartWorker, + onStopWorker, + onEditWorker, + onDeployWorker, + onWorkerWiringChange, + onWorkerWiringClear, }: FlatServiceCardProps) { - // Memoize hook options to avoid recreating on each render + // Memoize hook options to avoid recreating on each render. + // Use allConfigs (all service configs) for provider lookups — initialConfigs contains + // only this service's own compose configs and is wrong for filtering provider configs. const hookOptions = useMemo(() => { - if (providerTemplates && initialConfigs) { - return { initialTemplates: providerTemplates, initialConfigs } + if (providerTemplates && allConfigs) { + return { initialTemplates: providerTemplates, initialConfigs: allConfigs } + } + if (providerTemplates) { + return { initialTemplates: providerTemplates } } return undefined - }, [providerTemplates, initialConfigs]) - const [isStarting, setIsStarting] = useState(false) + }, [providerTemplates, allConfigs]) + const [startingWorker, setStartingWorker] = useState(null) + const [expandedWorkerId, setExpandedWorkerId] = useState(null) const [creatingCapability, setCreatingCapability] = useState(null) const [showDeployMenu, setShowDeployMenu] = useState(false) + const [showWorkersDeployMenu, setShowWorkersDeployMenu] = useState(false) + const [workersDrawerOpen, setWorkersDrawerOpen] = useState(false) const deployMenuRef = useRef(null) + const workersDeployMenuRef = useRef(null) // Close deploy menu when clicking outside useEffect(() => { @@ -441,19 +484,29 @@ export function FlatServiceCard({ return () => document.removeEventListener('mousedown', handleClickOutside) }, [showDeployMenu]) + // Close workers deploy menu when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (workersDeployMenuRef.current && !workersDeployMenuRef.current.contains(event.target as Node)) { + setShowWorkersDeployMenu(false) + } + } + if (showWorkersDeployMenu) { + document.addEventListener('mousedown', handleClickOutside) + } + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [showWorkersDeployMenu]) + // Compute state const isCloud = template.mode === 'cloud' const consumerId = config?.id || template.id const status = config?.status || 'pending' - const canStart = !isCloud && ['stopped', 'pending', 'not_running', 'not_found'].includes(status) - const canStop = !isCloud && ['running', 'starting'].includes(status) - // Get current provider for a capability from wiring const getProviderForCapability = useCallback( (capability: string): string | null => { const wire = wiring.find( - w => w.target_config_id === consumerId && w.target_capability === capability + w => w.target_config_id === consumerId && w.capability === capability ) return wire?.source_config_id || null }, @@ -471,27 +524,6 @@ export function FlatServiceCard({ [onWiringChange] ) - // Handle start/stop - const handleStartClick = async () => { - if (!onStart) return - setIsStarting(true) - try { - await onStart() - } finally { - setIsStarting(false) - } - } - - const handleStopClick = async () => { - if (!onStop) return - setIsStarting(true) - try { - await onStop() - } finally { - setIsStarting(false) - } - } - // Open create form for a capability const handleCreateNew = (capability: string) => { setCreatingCapability(capability) @@ -518,8 +550,22 @@ export function FlatServiceCard({ return providerTemplates.filter(t => t.provides === capability) } - // Card border color based on status + // Card border/background color based on deployment status const getCardClasses = () => { + // Check deployment statuses first + const hasRunningDeployment = deployments.some(d => d.status === 'running') + const hasDeployments = deployments.length > 0 + const hasStoppedOrFailedDeployment = deployments.some(d => + d.status === 'stopped' || d.status === 'failed' || d.status === 'error' + ) + + if (hasRunningDeployment) { + return 'border-success-400 dark:border-success-600 bg-success-50/50 dark:bg-success-900/10' + } + if (hasDeployments && hasStoppedOrFailedDeployment) { + return 'border-warning-400 dark:border-warning-600 bg-warning-50/50 dark:bg-warning-900/10' + } + // Fallback to local container status if (status === 'running') { return 'border-success-400 dark:border-success-600' } @@ -536,7 +582,7 @@ export function FlatServiceCard({
{/* Row 1: Service name + Edit */}
-
+
{/* Mode icon */} {isCloud ? ( @@ -545,127 +591,47 @@ export function FlatServiceCard({ )} {/* Service name */} - + {template.name} -
- - {/* Edit - top right */} - {onEdit && ( - - )} -
- {/* Row 2: Status + Actions */} -
- - - {/* Actions */} -
- {/* Start/Stop */} - {!isCloud && onStart && onStop && ( - <> - {isStarting ? ( - - - - ) : canStart ? ( - - ) : canStop ? ( - - ) : null} - + {tag} + + ))} +
)} +
- {/* Add config variant */} - {onAddConfig && ( + {/* Action buttons - top right */} +
+ {onFind && ( )} - - {/* Deploy dropdown */} - {onDeploy && ( -
- - - {/* Deploy target menu */} - {showDeployMenu && ( -
- - - -
- )} -
+ {onEdit && ( + )}
@@ -793,147 +759,489 @@ export function FlatServiceCard({
)} - {/* Deployments Section */} - {deployments && deployments.length > 0 && ( + {/* Deployments Section - always show if onDeploy available or has deployments */} + {(onDeploy || (deployments && deployments.length > 0)) && (
-
+
- Deployments ({deployments.length}) + Deployments {deployments && deployments.length > 0 ? `(${deployments.length})` : ''} -
-
- {deployments.map((deployment) => ( -
- {/* Row 1: Target + Status + Play/Stop */} -
-
- - - {deployment.unode_hostname} - -
-
- - {deployment.status} - + {/* Deploy button */} + {onDeploy && ( +
+ - {/* Stop/Restart button next to status */} - {(deployment.status === 'running' || deployment.status === 'deploying') && onStopDeployment ? ( - - ) : deployment.status === 'stopped' && onRestartDeployment ? ( - - ) : null} + {/* Deploy target menu */} + {showDeployMenu && ( +
+ + +
-
+ )} +
+ )} +
+ {deployments && deployments.length > 0 && (() => { + // Group deployments by node (unode_hostname) + const byNode = deployments.reduce((acc, d) => { + const node = d.unode_hostname || 'Local' + if (!acc[node]) acc[node] = [] + acc[node].push(d) + return acc + }, {} as Record) + + return ( +
+ {Object.entries(byNode).map(([nodeName, nodeDeployments]) => ( +
+ {/* Node name header */} +
+ + + {nodeName} + +
+ {/* Deployments on this node */} +
+ {nodeDeployments.map((deployment) => { + const shortContainerName = deployment.container_name + ?.replace(/^ushadow-\w+-/, '') + ?.replace(/-[a-f0-9]{8}$/, '') || deployment.container_name + const url = deployment.access_url || (deployment.exposed_port ? `http://localhost:${deployment.exposed_port}` : null) + const isRunning = deployment.status === 'running' || deployment.status === 'deploying' - {/* Row 2: Container + Ports */} -
- {deployment.container_name} - {deployment.deployed_config?.ports && deployment.deployed_config.ports.length > 0 && ( -
- {deployment.deployed_config.ports.map((portStr: string, idx: number) => { - const [externalPort, internalPort] = portStr.includes(':') - ? portStr.split(':') - : [portStr, portStr] return ( - - {externalPort}:{internalPort} - +
+ {/* Toggle switch */} + + {togglingDeployments.has(deployment.id) && ( + + )} + + {shortContainerName} + + {url && ( + e.stopPropagation()} + > + {url.replace(/^https?:\/\//, '')} + + )} +
+ +
+ {onEditDeployment && ( + + )} + {onRemoveDeployment && ( + + )} +
+
) })}
- )} -
+
+ ))} +
+ ) + })()} +
+ )} - {/* Row 3: URL + Actions */} -
- {(() => { - const url = deployment.access_url || (deployment.exposed_port ? `http://localhost:${deployment.exposed_port}` : null) - return url ? ( - - {url} - - ) : ( - No URL - ) - })()} + {/* Workers Section */} + {workers && workers.length > 0 && ( +
+
setWorkersDrawerOpen(!workersDrawerOpen)} + data-testid="workers-section-header" + > +
+ + + Workers ({workers.length}) + +
+
+ {/* Edit button */} + {onEditWorker && workers.length > 0 && ( + + )} + {/* Deploy button with worker selection dropdown */} + {onDeployWorker && workers.length > 0 && ( +
+ -
- {/* Edit */} - {onEditDeployment && ( - - )} + {/* Deploy target menu - same as main Deploy button */} + {showWorkersDeployMenu && ( +
+ + + +
+ )} +
+ )} +
+
- {/* Remove */} - {onRemoveDeployment && ( - + {/* Workers Capabilities Drawer */} + {workersDrawerOpen && ( +
+ {workers.map((worker) => { + const workerConsumerId = worker.config?.id || worker.template.id + const workerWiring = wiring.filter((w) => w.target_config_id === workerConsumerId) + + // Skip workers with no required capabilities + if (!worker.template.requires || worker.template.requires.length === 0) { + return ( +
+ {worker.template.name} + — No capabilities required +
+ ) + } + + return ( +
+
+ + {worker.template.name} + + {onEditWorker && ( + + )} +
+ {worker.template.description && ( +

+ {worker.template.description} +

)} +
+ {worker.template.requires.map((capability) => { + const wire = workerWiring.find( + (w) => w.capability === capability + ) + const currentProviderId = wire?.source_config_id || null + + return ( + { + const sourceId = option.isDefault ? option.templateId : option.id + if (onWorkerWiringChange) { + await onWorkerWiringChange(workerConsumerId, capability, sourceId) + } + }} + onCreateConfig={async (templateId, name, config) => { + const createdId = await onConfigCreate(templateId, name, config) + if (onWorkerWiringChange) { + await onWorkerWiringChange(workerConsumerId, capability, createdId) + } + }} + onEditConfig={onEditConfig} + onDeleteConfig={onDeleteConfig} + onUpdateConfig={onUpdateConfig} + onCreateNew={() => setCreatingCapability(capability)} + onClear={async () => { + if (onWorkerWiringClear) { + await onWorkerWiringClear(workerConsumerId, capability) + } + }} + hookOptions={hookOptions} + /> + ) + })} +
+ ) + })} +
+ )} + + {(() => { + // Collect all worker deployments and group by node + const allWorkerDeps: Array<{ worker: typeof workers[0], dep: any }> = [] + workers.forEach(worker => { + if (worker.deployments && worker.deployments.length > 0) { + worker.deployments.forEach((dep: any) => { + allWorkerDeps.push({ worker, dep }) + }) + } + }) + + // Group by node + const byNode = allWorkerDeps.reduce((acc, { worker, dep }) => { + const node = dep.unode_hostname || 'Local' + if (!acc[node]) acc[node] = [] + acc[node].push({ worker, dep }) + return acc + }, {} as Record) + + // If no deployments, show workers without node grouping + if (Object.keys(byNode).length === 0) { + return ( +
+ {workers.map((worker) => ( +
+ {worker.template.name} — No deployments +
+ ))}
+ ) + } + + return ( +
+ {Object.entries(byNode).map(([nodeName, items]) => ( +
+ {/* Node header */} +
+ + + {nodeName} + +
+ {/* Worker deployments on this node */} +
+ {items.map(({ worker, dep }) => { + const shortName = dep.container_name + ?.replace(/^ushadow-\w+-/, '') + ?.replace(/-[a-f0-9]{8}$/, '') || dep.container_name + const isDepRunning = dep.status === 'running' || dep.status === 'deploying' + + return ( +
+
+ + {togglingDeployments.has(dep.id) && ( + + )} + + {shortName} + +
+ {onRemoveDeployment && ( + + )} +
+ ) + })} +
+
+ ))}
- ))} -
+ ) + })()}
)}
diff --git a/ushadow/frontend/src/components/wiring/ProviderConfigDropdown.tsx b/ushadow/frontend/src/components/wiring/ProviderConfigDropdown.tsx index 42b8a1b4..418e4a1e 100644 --- a/ushadow/frontend/src/components/wiring/ProviderConfigDropdown.tsx +++ b/ushadow/frontend/src/components/wiring/ProviderConfigDropdown.tsx @@ -16,6 +16,7 @@ import { useState, useRef, useEffect, useCallback } from 'react' import { createPortal } from 'react-dom' +import { useFeatureFlags } from '../../contexts/FeatureFlagsContext' import { ChevronDown, ChevronRight, @@ -113,15 +114,18 @@ function Submenu({ option, template, savedConfigs, isEditingConfig, position, on if (!template?.id) return const fetchEnvConfig = async () => { + // Clear stale data from previous template before loading new one + setEnvVars([]) + setConfigs({}) setLoading(true) + + // Step 1: Fetch template env config (always required for field definitions) + let initial: Record = {} try { const response = await svcConfigsApi.getTemplateEnvConfig(template.id) const data = response.data - setEnvVars(data) - // Initialize configs from backend response (already has auto-mapping) - const initial: Record = {} for (const ev of data) { initial[ev.name] = { name: ev.name, @@ -130,15 +134,48 @@ function Submenu({ option, template, savedConfigs, isEditingConfig, position, on value: ev.value, } } - setConfigs(initial) } catch (err) { - console.error('Failed to fetch template env config:', err) - } finally { + console.error('[ProviderConfigDropdown] Failed to fetch template env config:', err) setLoading(false) + return } + + // Step 2: If editing a saved config, overlay its stored values on top of template defaults + if (isEditingConfig) { + console.log('[ProviderConfigDropdown] Fetching service config for overlay:', option.id) + try { + const svcResponse = await svcConfigsApi.getServiceConfig(option.id) + const svcValues = svcResponse.data.config?.values ?? {} + console.log('[ProviderConfigDropdown] Service config values:', svcValues) + + // Build capability key → env var name mapping from the template schema + const capKeyToEnvVar: Record = {} + for (const field of template.config_schema) { + capKeyToEnvVar[field.key] = field.env_var ?? field.key.toUpperCase() + } + console.log('[ProviderConfigDropdown] capKeyToEnvVar:', capKeyToEnvVar, 'initial keys:', Object.keys(initial)) + + // Overlay stored values - capability-keyed → env var name → update initial. + // Clear setting_path so EnvVarEditor shows the literal value, not the mapping dropdown. + const overlaid = { ...initial } + for (const [capKey, value] of Object.entries(svcValues)) { + const envName = capKeyToEnvVar[capKey] ?? capKey.toUpperCase() + if (overlaid[envName] && typeof value === 'string' && value) { + overlaid[envName] = { ...overlaid[envName], source: 'literal', value, setting_path: undefined } + } + } + initial = overlaid + } catch (err) { + console.error('[ProviderConfigDropdown] Failed to fetch service config overrides:', err) + // Fall through — show template defaults + } + } + + setConfigs(initial) + setLoading(false) } fetchEnvConfig() - }, [template?.id]) + }, [template?.id, isEditingConfig, option.id]) const hasChanges = Object.values(configs).some(c => c.source !== 'default' || c.value || c.setting_path) @@ -490,14 +527,33 @@ export function ProviderConfigDropdown({ disabled = false, error, }: ProviderConfigDropdownProps) { + const { isEnabled } = useFeatureFlags() + const serviceConfigsEnabled = isEnabled('service_configs') + + // When service_configs flag is off, hide saved configs + const effectiveOptions: GroupedProviders = serviceConfigsEnabled + ? options + : { defaults: options.defaults, saved: [] } + const [isOpen, setIsOpen] = useState(false) const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, width: 0 }) const [activeSubmenu, setActiveSubmenu] = useState<{ option: ProviderOption; top: number } | null>(null) + const [optimisticValue, setOptimisticValue] = useState(null) const dropdownRef = useRef(null) const triggerRef = useRef(null) const menuRef = useRef(null) const testIdBase = `provider-dropdown-${consumerId}-${capability}` + // Clear optimistic value when actual value updates (API completed) + useEffect(() => { + if (value?.id === optimisticValue?.id) { + setOptimisticValue(null) + } + }, [value, optimisticValue]) + + // The displayed value: optimistic takes priority for instant feedback + const displayValue = optimisticValue || value + // Calculate menu position when opening const updateMenuPosition = useCallback(() => { if (triggerRef.current) { @@ -554,9 +610,12 @@ export function ProviderConfigDropdown({ }, [activeSubmenu]) const handleSelect = (option: ProviderOption) => { - onChange(option) + // Set optimistic value immediately for instant feedback + setOptimisticValue(option) setIsOpen(false) setActiveSubmenu(null) + // Call onChange in background (don't await) + onChange(option) } const handleArrowClick = (option: ProviderOption, event: React.MouseEvent) => { @@ -580,7 +639,7 @@ export function ProviderConfigDropdown({ } } - const hasDefaults = options.defaults.length > 0 + const hasDefaults = effectiveOptions.defaults.length > 0 // Render the selected value display const renderSelectedValue = () => { @@ -593,7 +652,7 @@ export function ProviderConfigDropdown({ ) } - if (!value) { + if (!displayValue) { return ( Select provider... @@ -601,18 +660,18 @@ export function ProviderConfigDropdown({ ) } - const ModeIcon = value.mode === 'cloud' ? Cloud : HardDrive + const ModeIcon = displayValue.mode === 'cloud' ? Cloud : HardDrive return ( - {value.name} - {value.configSummary && ( + {displayValue.name} + {displayValue.configSummary && ( - - {value.configSummary} + - {displayValue.configSummary} )} - {value.isDefault && ( + {displayValue.isDefault && ( (default) )} @@ -765,8 +824,8 @@ export function ProviderConfigDropdown({ {/* Provider templates with saved configs indented underneath */} {hasDefaults && ( <> - {options.defaults.map(templateOption => { - const childConfigs = options.saved.filter(s => s.templateId === templateOption.templateId) + {effectiveOptions.defaults.map(templateOption => { + const childConfigs = effectiveOptions.saved.filter(s => s.templateId === templateOption.templateId) return (
{/* Template option */} @@ -774,44 +833,29 @@ export function ProviderConfigDropdown({ {/* Saved configs indented under this template */} {childConfigs.map(savedConfig => ( -
handleArrowClick(savedConfig, e)} + data-testid={`provider-option-config-${savedConfig.id}`} className={` - flex items-center text-sm pl-6 + w-full flex items-center gap-2 pl-9 pr-3 py-1.5 text-sm text-left hover:bg-neutral-100 dark:hover:bg-neutral-700 + ${activeSubmenu?.option.id === savedConfig.id ? 'bg-neutral-100 dark:bg-neutral-700' : ''} ${value?.id === savedConfig.id ? 'bg-primary-50 dark:bg-primary-900/30' : ''} `} > - - {/* Arrow to edit saved config */} - -
+ + {savedConfig.name} + {savedConfig.configSummary && ( + + - {savedConfig.configSummary} + + )} + {value?.id === savedConfig.id && ( + + )} + ))}
) @@ -844,16 +888,22 @@ export function ProviderConfigDropdown({
- {/* Submenu - slides out from right */} - {activeSubmenu && ( + {/* Submenu - slides out from right (or left when near screen edge) */} + {activeSubmenu && (() => { + const submenuWidth = 384 // min-w-96 + const rightEdge = menuPosition.left + menuPosition.width + 4 + submenuWidth + const submenuLeft = rightEdge < window.innerWidth + ? menuPosition.left + menuPosition.width + 4 + : menuPosition.left - submenuWidth - 4 + return ( s.templateId === activeSubmenu.option.templateId)} + savedConfigs={effectiveOptions.saved.filter(s => s.templateId === activeSubmenu.option.templateId)} isEditingConfig={!activeSubmenu.option.isDefault} position={{ top: activeSubmenu.top, - left: menuPosition.left + menuPosition.width + 4, + left: submenuLeft, }} onClose={() => setActiveSubmenu(null)} onSelect={() => handleSelect(activeSubmenu.option)} @@ -866,7 +916,8 @@ export function ProviderConfigDropdown({ onUpdateConfig={onUpdateConfig} onRefresh={onRefresh} /> - )} + ) + })()} , document.body )} diff --git a/ushadow/frontend/src/components/wiring/SystemOverview.tsx b/ushadow/frontend/src/components/wiring/SystemOverview.tsx index cdcde7d7..fb1fbc03 100644 --- a/ushadow/frontend/src/components/wiring/SystemOverview.tsx +++ b/ushadow/frontend/src/components/wiring/SystemOverview.tsx @@ -122,7 +122,7 @@ export function SystemOverview({ provider: { id: providerId, name: providerName, - capability: wire.source_capability, + capability: wire.capability, mode: sourceTemplate.mode, configured: sourceTemplate.configured, isTemplate: !sourceConfig, @@ -134,7 +134,7 @@ export function SystemOverview({ usageMap.get(providerId)!.consumers.push({ id: wire.target_config_id, name: targetConfig?.name || targetTemplate?.name || wire.target_config_id, - capability: wire.target_capability, + capability: wire.capability, status: targetConfig?.status || 'pending', }) } @@ -149,7 +149,7 @@ export function SystemOverview({ for (const consumer of consumersToCheck) { for (const cap of template.requires) { const isWired = wiring.some( - w => w.target_config_id === consumer.id && w.target_capability === cap + w => w.target_config_id === consumer.id && w.capability === cap ) if (!isWired) { unwired.push({ diff --git a/ushadow/frontend/src/components/wiring/WiringBoard.tsx b/ushadow/frontend/src/components/wiring/WiringBoard.tsx index c0e678be..5ee2cc71 100644 --- a/ushadow/frontend/src/components/wiring/WiringBoard.tsx +++ b/ushadow/frontend/src/components/wiring/WiringBoard.tsx @@ -97,9 +97,8 @@ interface ConsumerInfo { interface WiringInfo { id: string source_config_id: string - source_capability: string target_config_id: string - target_capability: string + capability: string } // Output wiring types @@ -389,7 +388,7 @@ export default function WiringBoard({ // Get provider for a specific consumer's capability slot const getProviderForSlot = (consumerId: string, capability: string) => { const wire = wiring.find( - (w) => w.target_config_id === consumerId && w.target_capability === capability + (w) => w.target_config_id === consumerId && w.capability === capability ) if (wire) { return { diff --git a/ushadow/frontend/src/contexts/AuthContext.tsx b/ushadow/frontend/src/contexts/AuthContext.tsx index 0566b154..9074c4b3 100644 --- a/ushadow/frontend/src/contexts/AuthContext.tsx +++ b/ushadow/frontend/src/contexts/AuthContext.tsx @@ -75,6 +75,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Verify token is still valid by making a request const response = await authApi.getMe() console.log('🔐 AuthContext: API call successful, user data:', response.data) + console.log('🔐 AuthContext: display_name:', response.data.display_name) + console.log('🔐 AuthContext: email:', response.data.email) setUser(response.data) setToken(savedToken) } catch (error) { @@ -104,6 +106,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Get user info const userResponse = await authApi.getMe() + console.log('🔐 AuthContext: Login successful, user data:', userResponse.data) + console.log('🔐 AuthContext: User display_name:', userResponse.data.display_name) setUser(userResponse.data) return { success: true } diff --git a/ushadow/frontend/src/contexts/CasdoorAuthContext.tsx b/ushadow/frontend/src/contexts/CasdoorAuthContext.tsx new file mode 100644 index 00000000..a0af8ed1 --- /dev/null +++ b/ushadow/frontend/src/contexts/CasdoorAuthContext.tsx @@ -0,0 +1,369 @@ +/** + * Casdoor Authentication Context + * + * Provides OIDC authentication using Casdoor as the sole auth provider. + */ + +import { createContext, useContext, useState, useEffect, ReactNode } from 'react' +import { TokenManager } from '../auth/TokenManager' +import { casdoorConfig, backendConfig } from '../auth/config' +import { authApi } from '../services/api' +import type { User } from '../types/user' + +export interface CasdoorAuthContextType { + isAuthenticated: boolean + isLoading: boolean + userInfo: any | null + user: User | null // MongoDB user data from /api/auth/me + login: (redirectUri?: string) => void + register: (redirectUri?: string) => void + logout: (redirectUri?: string) => void + getAccessToken: () => Promise + handleCallback: (code: string, state: string) => Promise +} + +export const CasdoorAuthContext = createContext(undefined) + +export function CasdoorAuthProvider({ children }: { children: ReactNode }) { + // Initialize auth state synchronously to prevent flash of unauthenticated state + const initialAuthState = TokenManager.isAuthenticated() + const initialUserInfo = initialAuthState ? TokenManager.getUserInfo() : null + + const [isAuthenticated, setIsAuthenticated] = useState(initialAuthState) + const [isLoading, setIsLoading] = useState(initialAuthState) // Loading if authenticated (need to fetch user data) + const [userInfo, setUserInfo] = useState(initialUserInfo) + const [user, setUser] = useState(null) // MongoDB user data + const [refreshTimeoutId, setRefreshTimeoutId] = useState(null) + + // Function to fetch MongoDB user data + const fetchUserData = async () => { + setIsLoading(true) + try { + console.log('[CASDOOR-AUTH] Fetching user data from /api/auth/me...') + const response = await authApi.getMe() + console.log('[CASDOOR-AUTH] User data received:', response.data) + console.log('[CASDOOR-AUTH] display_name:', response.data.display_name) + setUser(response.data) + } catch (error) { + console.error('[CASDOOR-AUTH] Failed to fetch user data:', error) + setUser(null) + } finally { + setIsLoading(false) + } + } + + // Function to set up automatic token refresh + const setupTokenRefresh = () => { + try { + // Clear any existing refresh timeout + if (refreshTimeoutId) { + console.log('[CASDOOR-AUTH] Clearing existing token refresh timeout') + clearTimeout(refreshTimeoutId) + setRefreshTimeoutId(null) + } + + const token = TokenManager.getAccessTokenSync() + const refreshToken = TokenManager.getRefreshToken() + + if (!token || !refreshToken) { + console.log('[CASDOOR-AUTH] No token or refresh token found, skipping refresh setup') + return + } + + // Check if refresh token has expired + const refreshExpiry = TokenManager.getRefreshTokenExpiry() + if (refreshExpiry && refreshExpiry.expiresIn <= 0) { + console.warn('[CASDOOR-AUTH] Refresh token expired, cannot set up refresh') + setIsAuthenticated(false) + setUserInfo(null) + setUser(null) + TokenManager.clearTokens() + return + } + + // Use OAuth2 standard: get expiry from stored expires_in (not JWT decode) + const expiry = TokenManager.getTokenExpiry() + if (!expiry) { + console.log('[CASDOOR-AUTH] No expiry info stored, skipping refresh setup') + return + } + + const { expiresAt, expiresIn } = expiry + + // If token is already expired or expires in less than 0 seconds, clear it + if (expiresIn <= 0) { + console.warn('[CASDOOR-AUTH] Token already expired, clearing tokens...') + setIsAuthenticated(false) + setUserInfo(null) + setUser(null) + TokenManager.clearTokens() + return + } + + // Refresh well before SSO session idle timeout (30min = 1800s) + // Refresh at 25 minutes (1500s) or 60s before token expiry, whichever is sooner + const refreshAt = Math.max(0, Math.min(expiresIn - 60, 1500)) + + console.log('[CASDOOR-AUTH] Setting up token refresh (OAuth2 standard):', { + expiresAt: new Date(expiresAt * 1000).toISOString(), + expiresIn: `${Math.floor(expiresIn / 60)}m ${expiresIn % 60}s`, + refreshIn: `${Math.floor(refreshAt / 60)}m ${refreshAt % 60}s` + }) + + const timeoutId = setTimeout(async () => { + try { + console.log('[CASDOOR-AUTH] Refreshing token...') + + // Check if we have a refresh token + const refreshToken = TokenManager.getRefreshToken() + if (!refreshToken) { + throw new Error('No refresh token available') + } + + // Refresh via backend (handles Casdoor communication) + const response = await fetch(`${backendConfig.url}/api/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + refresh_token: refreshToken, + }), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Token refresh failed: ${response.status} ${errorText}`) + } + + const newTokens = await response.json() + TokenManager.storeTokens(newTokens) + console.log('[CASDOOR-AUTH] ✅ Token refreshed successfully') + + // Update context state + setIsAuthenticated(true) + const info = TokenManager.getUserInfo() + setUserInfo(info) + + // Fetch fresh user data + await fetchUserData() + + // Schedule next refresh + setupTokenRefresh() + } catch (error) { + console.error('[CASDOOR-AUTH] ❌ Token refresh failed:', error) + // Token refresh failed - clear auth state (will trigger redirect to login) + setIsAuthenticated(false) + setUserInfo(null) + setUser(null) + TokenManager.clearTokens() + } + }, refreshAt * 1000) + + setRefreshTimeoutId(timeoutId) + console.log('[CASDOOR-AUTH] ✅ Token refresh scheduled') + } catch (error) { + console.error('[CASDOOR-AUTH] Error setting up token refresh:', error) + } + } + + useEffect(() => { + // Clean up any stale "null" or "undefined" string values in sessionStorage + TokenManager.cleanupStaleTokens() + + // Check auth state with launcher support (async) + const checkAuth = async () => { + console.log('[CASDOOR-AUTH] Checking authentication (launcher-aware)...') + const authenticated = await TokenManager.isAuthenticatedAsync() + console.log('[CASDOOR-AUTH] Authentication result:', authenticated) + + if (authenticated !== isAuthenticated) { + setIsAuthenticated(authenticated) + if (authenticated) { + const info = TokenManager.getUserInfo() + setUserInfo(info) + // Fetch MongoDB user data + fetchUserData() + // Set up token refresh + setupTokenRefresh() + } else { + setUserInfo(null) + setUser(null) + setIsLoading(false) + } + } else if (authenticated && !user) { + // If already authenticated but no user data, fetch it + fetchUserData() + // Set up token refresh if not already set + if (!refreshTimeoutId) { + setupTokenRefresh() + } + } else if (!authenticated) { + setIsLoading(false) + } + } + + checkAuth() + + // Listen for token updates from launcher + const handleMessage = (event: MessageEvent) => { + console.log('[CASDOOR-AUTH] Received postMessage:', event.data.type) + if (event.data.type === 'CASDOOR_TOKENS_UPDATED') { + console.log('[CASDOOR-AUTH] Token update notification received from launcher!') + console.log('[CASDOOR-AUTH] Re-checking authentication...') + checkAuth() + } + } + + window.addEventListener('message', handleMessage) + console.log('[CASDOOR-AUTH] ✓ Message listener registered') + + // Clean up on unmount + return () => { + window.removeEventListener('message', handleMessage) + if (refreshTimeoutId) { + console.log('[CASDOOR-AUTH] Cleaning up token refresh timeout on unmount') + clearTimeout(refreshTimeoutId) + } + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const login = async (redirectUri?: string) => { + // Save current location for return after login + const returnUrl = redirectUri || window.location.pathname + window.location.search + sessionStorage.setItem('login_return_url', returnUrl) + + // Generate CSRF state + const state = generateState() + sessionStorage.setItem('oauth_state', state) + + // Build Casdoor login URL (async because of PKCE SHA-256) + const loginUrl = await TokenManager.buildLoginUrl({ + baseUrl: casdoorConfig.url, + clientId: casdoorConfig.clientId, + authEndpoint: casdoorConfig.authEndpoint, + redirectUri: `${window.location.origin}/oauth/callback`, + state, + }) + + // Redirect to Casdoor + window.location.href = loginUrl + } + + const register = async (redirectUri?: string) => { + // Save current location for return after registration + const returnUrl = redirectUri || window.location.pathname + window.location.search + sessionStorage.setItem('login_return_url', returnUrl) + + // Generate CSRF state + const state = generateState() + sessionStorage.setItem('oauth_state', state) + + const signupUrl = await TokenManager.buildLoginUrl({ + baseUrl: casdoorConfig.url, + clientId: casdoorConfig.clientId, + authEndpoint: casdoorConfig.signupEndpoint, + redirectUri: `${window.location.origin}/oauth/callback`, + state, + }) + + window.location.href = signupUrl + } + + const logout = (redirectUri?: string) => { + // Build logout URL FIRST (needs id_token from storage) + const defaultRedirectUri = `${window.location.origin}/` + const logoutUrl = TokenManager.buildLogoutUrl({ + baseUrl: casdoorConfig.url, + logoutEndpoint: casdoorConfig.logoutEndpoint, + redirectUri: redirectUri || defaultRedirectUri, + }) + + // THEN clear tokens (after we've read id_token for logout URL) + TokenManager.clearTokens() + setIsAuthenticated(false) + setUserInfo(null) + setUser(null) + + // Redirect to Casdoor logout + window.location.href = logoutUrl + } + + const handleCallback = async (code: string, state: string) => { + // Verify state (CSRF protection) + const savedState = sessionStorage.getItem('oauth_state') + if (state !== savedState) { + throw new Error('Invalid state parameter - possible CSRF attack') + } + + // Exchange code for tokens via backend + const tokens = await TokenManager.exchangeCodeForTokens(code, backendConfig.url) + console.log('[CASDOOR-AUTH] Received tokens:', { + hasAccessToken: !!tokens.access_token, + hasRefreshToken: !!tokens.refresh_token, + hasIdToken: !!tokens.id_token, + tokenPreview: tokens.access_token?.substring(0, 30) + '...' + }) + + // Store tokens + TokenManager.storeTokens(tokens) + console.log('[CASDOOR-AUTH] Tokens stored in localStorage') + + // Verify storage worked + const storedToken = localStorage.getItem('kc_access_token') + console.log('[CASDOOR-AUTH] Verified storage:', { + hasStoredToken: !!storedToken, + storedTokenPreview: storedToken?.substring(0, 30) + '...' + }) + + // Update auth state + setIsAuthenticated(true) + const info = TokenManager.getUserInfo() + setUserInfo(info) + + // Fetch MongoDB user data + await fetchUserData() + + // Set up automatic token refresh + setupTokenRefresh() + + // Clean up + sessionStorage.removeItem('oauth_state') + } + + const getAccessToken = async () => { + return await TokenManager.getAccessToken() + } + + return ( + + {children} + + ) +} + +export function useCasdoorAuth() { + const context = useContext(CasdoorAuthContext) + if (context === undefined) { + throw new Error('useCasdoorAuth must be used within a CasdoorAuthProvider') + } + return context +} + +// Helper function +function generateState(): string { + return Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15) +} diff --git a/ushadow/frontend/src/contexts/ChronicleContext.tsx b/ushadow/frontend/src/contexts/ChronicleContext.tsx index ba7d5374..5672c5ef 100644 --- a/ushadow/frontend/src/contexts/ChronicleContext.tsx +++ b/ushadow/frontend/src/contexts/ChronicleContext.tsx @@ -68,13 +68,10 @@ export function ChronicleProvider({ children }: { children: ReactNode }) { setConnectionError(null) }, [recording]) - // Auto-check connection on mount so the header record button appears immediately - useEffect(() => { - // Only check once on mount - checkConnection() - }, []) // eslint-disable-line react-hooks/exhaustive-deps + // DON'T auto-check connection on mount - let user initiate (record button, Chronicle page, etc.) + // This avoids unnecessary requests when user isn't using Chronicle - // Re-check connection periodically (every 5 minutes) if connected + // Re-check connection periodically (every 5 minutes) ONLY if already connected useEffect(() => { if (!isConnected) return diff --git a/ushadow/frontend/src/contexts/ServicesContext.tsx b/ushadow/frontend/src/contexts/ServicesContext.tsx index 7fabc52b..449a38f6 100644 --- a/ushadow/frontend/src/contexts/ServicesContext.tsx +++ b/ushadow/frontend/src/contexts/ServicesContext.tsx @@ -323,18 +323,27 @@ export function ServicesProvider({ children }: { children: ReactNode }) { }, []) const toggleEnabled = useCallback(async (serviceId: string, currentEnabled: boolean) => { + const newEnabled = !currentEnabled + + // Optimistically update the UI immediately + setServiceServiceConfigs(prev => + prev.map(s => s.service_id === serviceId ? { ...s, enabled: newEnabled } : s) + ) + + // Set toggling state to show pending appearance setTogglingEnabled(serviceId) + try { - const newEnabled = !currentEnabled + // Make the API call await servicesApi.setEnabled(serviceId, newEnabled) - setServiceServiceConfigs(prev => - prev.map(s => s.service_id === serviceId ? { ...s, enabled: newEnabled } : s) - ) - const action = newEnabled ? 'enabled' : 'disabled' setMessage({ type: 'success', text: `Service ${action}` }) } catch (error: any) { + // Revert the optimistic update on error + setServiceServiceConfigs(prev => + prev.map(s => s.service_id === serviceId ? { ...s, enabled: currentEnabled } : s) + ) setMessage({ type: 'error', text: error.response?.data?.detail || 'Failed to toggle service' }) } finally { setTogglingEnabled(null) diff --git a/ushadow/frontend/src/contexts/SettingsContext.tsx b/ushadow/frontend/src/contexts/SettingsContext.tsx new file mode 100644 index 00000000..7233b36c --- /dev/null +++ b/ushadow/frontend/src/contexts/SettingsContext.tsx @@ -0,0 +1,55 @@ +import { createContext, useContext, useState, useEffect, ReactNode } from 'react' +import { settingsApi } from '../services/api' +import { updateAuthConfig } from '../auth/config' + +interface SettingsContextType { + settings: Record | null + isLoading: boolean + refreshSettings: () => Promise +} + +const SettingsContext = createContext(undefined) + +export function SettingsProvider({ children }: { children: ReactNode }) { + const [settings, setSettings] = useState | null>(null) + const [isLoading, setIsLoading] = useState(true) + + const fetchSettings = async () => { + try { + const response = await settingsApi.getConfig() + setSettings(response.data) + + updateAuthConfig(response.data) + console.log('[SettingsContext] Settings loaded and auth config updated') + } catch (error) { + console.error('[SettingsContext] Failed to load settings:', error) + // Don't block app initialization if settings fail + setSettings({}) + } finally { + setIsLoading(false) + } + } + + const refreshSettings = async () => { + setIsLoading(true) + await fetchSettings() + } + + useEffect(() => { + fetchSettings() + }, []) + + return ( + + {children} + + ) +} + +export function useSettings() { + const context = useContext(SettingsContext) + if (context === undefined) { + throw new Error('useSettings must be used within a SettingsProvider') + } + return context +} diff --git a/ushadow/frontend/src/contexts/WizardContext.tsx b/ushadow/frontend/src/contexts/WizardContext.tsx index a0373696..9e65d416 100644 --- a/ushadow/frontend/src/contexts/WizardContext.tsx +++ b/ushadow/frontend/src/contexts/WizardContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, useEffect, ReactNode, useMemo } from 'react' +import { createContext, useContext, useState, useEffect, useRef, ReactNode, useMemo } from 'react' import { LucideIcon } from 'lucide-react' import { api } from '../services/api' import { getIconForLevel } from '../wizards/registry' @@ -77,6 +77,7 @@ const initialState: WizardState = { export function WizardProvider({ children }: { children: ReactNode }) { const [wizardState, setWizardState] = useState(initialState) + const initializedRef = useRef(false) // Fetch state from backend const fetchState = () => { @@ -96,8 +97,10 @@ export function WizardProvider({ children }: { children: ReactNode }) { .catch(() => {}) } - // Load state from backend on mount + // Load state from backend on mount (ref guard prevents StrictMode double-fetch) useEffect(() => { + if (initializedRef.current) return + initializedRef.current = true fetchState() }, []) diff --git a/ushadow/frontend/src/hooks/index.ts b/ushadow/frontend/src/hooks/index.ts index 218eac1e..bb659ee4 100644 --- a/ushadow/frontend/src/hooks/index.ts +++ b/ushadow/frontend/src/hooks/index.ts @@ -50,3 +50,7 @@ export type { UseServiceCatalogResult } from './useServiceCatalog'; export { useDeploymentActions } from './useDeploymentActions'; export type { UseDeploymentActionsResult } from './useDeploymentActions'; + +// Sharing hooks +export { useShare } from './useShare'; +export type { UseShareOptions, UseShareReturn } from './useShare'; diff --git a/ushadow/frontend/src/hooks/useConversationDetail.ts b/ushadow/frontend/src/hooks/useConversationDetail.ts new file mode 100644 index 00000000..84a915b6 --- /dev/null +++ b/ushadow/frontend/src/hooks/useConversationDetail.ts @@ -0,0 +1,65 @@ +import { useQuery } from '@tanstack/react-query' +import { chronicleConversationsApi } from '../services/chronicleApi' +import { myceliaApi } from '../services/api' +import type { Conversation } from '../services/chronicleApi' +import type { ConversationSource } from './useConversations' + +/** + * Fetch a single conversation from Chronicle + */ +export function useChronicleConversation(id: string, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: ['conversation', 'chronicle', id], + queryFn: async () => { + console.log('[useChronicleConversation] Fetching conversation:', id) + const response = await chronicleConversationsApi.getById(id) + console.log('[useChronicleConversation] Response:', response) + + // Handle different response formats + const data = response.data + if (data && typeof data === 'object' && 'conversation' in data) { + return (data as any).conversation as Conversation + } + return data as Conversation + }, + enabled: options?.enabled !== false && !!id, + retry: false, + staleTime: 60000, // Consider fresh for 60s + }) +} + +/** + * Fetch a single conversation from Mycelia + */ +export function useMyceliaConversation(id: string, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: ['conversation', 'mycelia', id], + queryFn: async () => { + console.log('[useMyceliaConversation] Fetching conversation:', id) + const response = await myceliaApi.getConversation(id) + console.log('[useMyceliaConversation] Response:', response) + return response.data as Conversation + }, + enabled: options?.enabled !== false && !!id, + retry: false, + staleTime: 60000, + }) +} + +/** + * Fetch a conversation from the specified source + */ +export function useConversationDetail(id: string, source: ConversationSource) { + const chronicle = useChronicleConversation(id, { enabled: source === 'chronicle' }) + const mycelia = useMyceliaConversation(id, { enabled: source === 'mycelia' }) + + const activeQuery = source === 'chronicle' ? chronicle : mycelia + + return { + conversation: activeQuery.data, + isLoading: activeQuery.isLoading, + error: activeQuery.error, + refetch: activeQuery.refetch, + source, + } +} diff --git a/ushadow/frontend/src/hooks/useConversations.ts b/ushadow/frontend/src/hooks/useConversations.ts new file mode 100644 index 00000000..65fd3d44 --- /dev/null +++ b/ushadow/frontend/src/hooks/useConversations.ts @@ -0,0 +1,111 @@ +import { useQuery } from '@tanstack/react-query' +import { chronicleApi, myceliaApi } from '../services/api' +import { chronicleConversationsApi } from '../services/chronicleApi' +import type { Conversation } from '../services/chronicleApi' + +export type ConversationSource = 'chronicle' | 'mycelia' + +interface ConversationsResponse { + conversations: Conversation[] + count: number +} + +/** + * Fetch conversations from Chronicle + */ +export function useChronicleConversations(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: ['conversations', 'chronicle'], + queryFn: async () => { + const response = await chronicleConversationsApi.getAll() + // Handle different response formats + const data = response.data + + // If data is already an array, return it + if (Array.isArray(data)) { + return data as Conversation[] + } + + // If data has a conversations field, return that + if (data && typeof data === 'object' && 'conversations' in data) { + return (data as any).conversations as Conversation[] + } + + // Otherwise return empty array + console.warn('[useChronicleConversations] Unexpected response format:', data) + return [] + }, + enabled: options?.enabled !== false, + retry: false, + staleTime: 30000, // Consider fresh for 30s + }) +} + +/** + * Fetch conversations from Mycelia + */ +export function useMyceliaConversations(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: ['conversations', 'mycelia'], + queryFn: async () => { + try { + const response = await myceliaApi.getConversations({ limit: 25 }) + const data = response.data + + // If data has conversations field, return that + if (data && typeof data === 'object' && 'conversations' in data) { + return ((data as any).conversations || []) as Conversation[] + } + + // If data is already an array, return it + if (Array.isArray(data)) { + return data as Conversation[] + } + + // Otherwise return empty array + console.warn('[useMyceliaConversations] Unexpected response format:', data) + return [] + } catch (error) { + console.error('[useMyceliaConversations] Error fetching conversations:', error) + return [] + } + }, + enabled: options?.enabled !== false, + retry: false, + staleTime: 30000, + }) +} + +/** + * Fetch conversations from multiple sources + * Returns a map of source -> conversations + */ +export function useMultiSourceConversations(enabledSources: ConversationSource[]) { + const chronicleEnabled = enabledSources.includes('chronicle') + const myceliaEnabled = enabledSources.includes('mycelia') + + const chronicle = useChronicleConversations({ enabled: chronicleEnabled }) + const mycelia = useMyceliaConversations({ enabled: myceliaEnabled }) + + // Ensure data is always an array + const chronicleData = Array.isArray(chronicle.data) ? chronicle.data : [] + const myceliaData = Array.isArray(mycelia.data) ? mycelia.data : [] + + return { + chronicle: { + data: chronicleData, + isLoading: chronicle.isLoading, + error: chronicle.error, + refetch: chronicle.refetch, + }, + mycelia: { + data: myceliaData, + isLoading: mycelia.isLoading, + error: mycelia.error, + refetch: mycelia.refetch, + }, + // Aggregate states + anyLoading: chronicle.isLoading || mycelia.isLoading, + allLoaded: (!chronicleEnabled || !chronicle.isLoading) && (!myceliaEnabled || !mycelia.isLoading), + } +} diff --git a/ushadow/frontend/src/hooks/useDashboardData.ts b/ushadow/frontend/src/hooks/useDashboardData.ts new file mode 100644 index 00000000..d7559bb8 --- /dev/null +++ b/ushadow/frontend/src/hooks/useDashboardData.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query' +import { dashboardApi, type DashboardData } from '../services/api' + +/** + * Hook to fetch dashboard data (stats + recent conversations & memories). + * Automatically refetches every 30 seconds to keep data fresh. + */ +export function useDashboardData(conversationLimit = 10, memoryLimit = 10) { + return useQuery({ + queryKey: ['dashboard', 'data', conversationLimit, memoryLimit], + queryFn: async () => { + const response = await dashboardApi.getDashboardData(conversationLimit, memoryLimit) + return response.data + }, + refetchInterval: 30000, // Refresh every 30 seconds + staleTime: 10000, // Consider data stale after 10 seconds + }) +} diff --git a/ushadow/frontend/src/hooks/useDashboardStats.ts b/ushadow/frontend/src/hooks/useDashboardStats.ts new file mode 100644 index 00000000..f7a0ca89 --- /dev/null +++ b/ushadow/frontend/src/hooks/useDashboardStats.ts @@ -0,0 +1,78 @@ +import { useQuery } from '@tanstack/react-query' +import { conversationsApi, servicesApi } from '../services/api' + +interface ConversationsResponse { + conversations: any[] + total: number + page: number + limit: number + source: string + breakdown?: { + chronicle: number + mycelia: number + } +} + +/** + * Fetch unified conversations count from all sources + */ +export function useConversationsCount() { + return useQuery({ + queryKey: ['dashboard', 'conversations-count'], + queryFn: async () => { + const response = await conversationsApi.getAll({ source: 'all', page: 1, limit: 1 }) + const data = response.data as ConversationsResponse + return data.total || 0 + }, + staleTime: 60000, // 60 seconds (increased from 30s) + retry: 1, + }) +} + +/** + * Fetch count of MCP servers from services + */ +export function useMcpServersCount() { + return useQuery({ + queryKey: ['dashboard', 'mcp-servers-count'], + queryFn: async () => { + try { + const response = await servicesApi.getInstalled() + const services = response.data + // Count services with MCP capability + const mcpServices = Array.isArray(services) + ? services.filter((s: any) => s.capabilities?.includes('mcp')) + : [] + return mcpServices.length + } catch (error) { + console.error('Error fetching MCP servers:', error) + return 0 + } + }, + staleTime: 60000, // 60 seconds (increased from 30s) + retry: 1, + }) +} + +/** + * Fetch all dashboard stats at once + */ +export function useDashboardStats() { + const conversationsCount = useConversationsCount() + const mcpServersCount = useMcpServersCount() + + return { + conversations: { + count: conversationsCount.data ?? 0, + isLoading: conversationsCount.isLoading, + error: conversationsCount.error, + }, + mcpServers: { + count: mcpServersCount.data ?? 0, + isLoading: mcpServersCount.isLoading, + error: mcpServersCount.error, + }, + isLoading: conversationsCount.isLoading || mcpServersCount.isLoading, + hasError: !!conversationsCount.error || !!mcpServersCount.error, + } +} diff --git a/ushadow/frontend/src/hooks/useFeed.ts b/ushadow/frontend/src/hooks/useFeed.ts new file mode 100644 index 00000000..1f998c81 --- /dev/null +++ b/ushadow/frontend/src/hooks/useFeed.ts @@ -0,0 +1,234 @@ +/** + * useFeed Hooks + * + * React Query hooks for the personalized multi-platform feed feature. + * Provides hooks for posts, interests, sources, refresh, and post actions. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { feedApi, type FeedPost, type SourceCreateData, type GraphUserInterestsResponse } from '../services/feedApi' +import { useAuth } from '../contexts/AuthContext' +import { useCasdoorAuth } from '../contexts/CasdoorAuthContext' + +/** Resolve user email from whichever auth provider is active. */ +function useUserId(): { userId: string; isLoadingUser: boolean } { + const { user: legacyUser, isLoading: legacyLoading } = useAuth() + const { isAuthenticated: kcAuthenticated, user: kcUser, isLoading: kcLoading } = useCasdoorAuth() + + const user = kcAuthenticated && kcUser ? kcUser : legacyUser + return { + userId: user?.email || 'ushadow', + isLoadingUser: legacyLoading || kcLoading, + } +} + +// --------------------------------------------------------------------------- +// Feed Posts +// --------------------------------------------------------------------------- + +export function useFeedPosts( + page: number = 1, + pageSize: number = 20, + interest?: string, + showSeen: boolean = true, + platformType?: string, +) { + const { userId, isLoadingUser } = useUserId() + const queryClient = useQueryClient() + + const postsQuery = useQuery({ + queryKey: ['feedPosts', userId, page, pageSize, interest, showSeen, platformType], + queryFn: () => + feedApi.getPosts({ + page, + page_size: pageSize, + interest, + show_seen: showSeen, + platform_type: platformType, + }).then(r => r.data), + staleTime: 60_000, + enabled: !isLoadingUser, + }) + + const markSeenMutation = useMutation({ + mutationFn: (postId: string) => feedApi.markSeen(postId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['feedPosts'] }) + queryClient.invalidateQueries({ queryKey: ['feedStats'] }) + }, + }) + + const bookmarkMutation = useMutation({ + mutationFn: (postId: string) => feedApi.bookmarkPost(postId), + onMutate: async (postId: string) => { + await queryClient.cancelQueries({ queryKey: ['feedPosts'] }) + queryClient.setQueriesData<{ posts: FeedPost[] }>( + { queryKey: ['feedPosts'] }, + (old) => { + if (!old) return old + return { + ...old, + posts: old.posts.map((p) => + p.post_id === postId ? { ...p, bookmarked: !p.bookmarked } : p, + ), + } + }, + ) + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['feedPosts'] }) + queryClient.invalidateQueries({ queryKey: ['feedStats'] }) + }, + }) + + return { + posts: postsQuery.data?.posts ?? [], + total: postsQuery.data?.total ?? 0, + totalPages: postsQuery.data?.total_pages ?? 1, + isLoading: postsQuery.isLoading, + isFetching: postsQuery.isFetching, + error: postsQuery.error, + refetch: postsQuery.refetch, + + markSeen: markSeenMutation.mutate, + toggleBookmark: bookmarkMutation.mutate, + } +} + +// --------------------------------------------------------------------------- +// Interests +// --------------------------------------------------------------------------- + +export function useFeedInterests() { + const { userId, isLoadingUser } = useUserId() + + const query = useQuery({ + queryKey: ['feedInterests', userId], + queryFn: () => feedApi.getInterests().then(r => r.data), + staleTime: 120_000, + enabled: !isLoadingUser, + }) + + return { + interests: query.data?.interests ?? [], + isLoading: query.isLoading, + error: query.error, + } +} + +// --------------------------------------------------------------------------- +// Sources +// --------------------------------------------------------------------------- + +export function useFeedSources() { + const { userId, isLoadingUser } = useUserId() + const queryClient = useQueryClient() + + const sourcesQuery = useQuery({ + queryKey: ['feedSources', userId], + queryFn: () => feedApi.getSources().then(r => r.data), + staleTime: 120_000, + enabled: !isLoadingUser, + }) + + const addMutation = useMutation({ + mutationFn: (data: SourceCreateData) => + feedApi.addSource(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['feedSources'] }) + queryClient.invalidateQueries({ queryKey: ['feedStats'] }) + }, + }) + + const removeMutation = useMutation({ + mutationFn: (sourceId: string) => feedApi.removeSource(sourceId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['feedSources'] }) + queryClient.invalidateQueries({ queryKey: ['feedStats'] }) + }, + }) + + return { + sources: sourcesQuery.data?.sources ?? [], + isLoading: sourcesQuery.isLoading, + error: sourcesQuery.error, + + addSource: addMutation.mutateAsync, + isAdding: addMutation.isPending, + + removeSource: removeMutation.mutateAsync, + isRemoving: removeMutation.isPending, + } +} + +// --------------------------------------------------------------------------- +// Refresh +// --------------------------------------------------------------------------- + +export function useRefreshFeed(platformType?: string) { + const queryClient = useQueryClient() + + const mutation = useMutation({ + mutationFn: () => feedApi.refresh(platformType).then(r => r.data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['feedPosts'] }) + queryClient.invalidateQueries({ queryKey: ['feedInterests'] }) + queryClient.invalidateQueries({ queryKey: ['feedStats'] }) + }, + }) + + return { + refresh: mutation.mutateAsync, + isRefreshing: mutation.isPending, + lastResult: mutation.data ?? null, + error: mutation.error, + } +} + +// --------------------------------------------------------------------------- +// Graph Interests (new mem0 /api/v1/graph/interests endpoint) +// --------------------------------------------------------------------------- + +export function useGraphInterests() { + const { userId, isLoadingUser } = useUserId() + const queryClient = useQueryClient() + + const query = useQuery({ + queryKey: ['graphInterests', userId], + queryFn: () => feedApi.getGraphInterests(userId).then(r => r.data), + staleTime: 120_000, + enabled: !isLoadingUser, + }) + + const forceRefresh = async () => { + await queryClient.invalidateQueries({ queryKey: ['graphInterests', userId] }) + } + + return { + data: query.data ?? null, + isLoading: query.isLoading, + isFetching: query.isFetching, + error: query.error, + forceRefresh, + } +} + +// --------------------------------------------------------------------------- +// Stats +// --------------------------------------------------------------------------- + +export function useFeedStats() { + const { userId, isLoadingUser } = useUserId() + + const query = useQuery({ + queryKey: ['feedStats', userId], + queryFn: () => feedApi.getStats().then(r => r.data), + staleTime: 30_000, + enabled: !isLoadingUser, + }) + + return { + stats: query.data ?? null, + isLoading: query.isLoading, + } +} diff --git a/ushadow/frontend/src/hooks/useGraphApi.ts b/ushadow/frontend/src/hooks/useGraphApi.ts index 7a498491..ff86eac7 100644 --- a/ushadow/frontend/src/hooks/useGraphApi.ts +++ b/ushadow/frontend/src/hooks/useGraphApi.ts @@ -8,13 +8,33 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { graphApi, type MemorySource } from '../services/api' import { useAuth } from '../contexts/AuthContext' +import { useCasdoorAuth } from '../contexts/CasdoorAuthContext' import type { GraphData } from '../types/graph' const FALLBACK_USER_ID = 'ushadow' export function useGraphApi(limit: number = 100, source: MemorySource = 'openmemory') { - const { user } = useAuth() + // Check both auth contexts (Casdoor and legacy) + const legacyAuth = useAuth() + const casdoorAuth = useCasdoorAuth() + + // Prefer Casdoor auth if authenticated, fall back to legacy auth + const user = casdoorAuth.isAuthenticated ? casdoorAuth.user : legacyAuth.user const userId = user?.email || FALLBACK_USER_ID + + // Diagnostic logging for user email resolution + if (!user) { + console.warn('[useGraphApi] No user object available from either auth context, falling back to:', FALLBACK_USER_ID) + console.warn('[useGraphApi] Auth state:', { + casdoorAuth: { isAuthenticated: casdoorAuth.isAuthenticated, hasUser: !!casdoorAuth.user }, + legacyAuth: { hasUser: !!legacyAuth.user, hasToken: !!legacyAuth.token } + }) + } else if (!user.email) { + console.warn('[useGraphApi] User object exists but email is missing:', { user, userId: FALLBACK_USER_ID }) + } else { + console.log('[useGraphApi] Using user email as ID:', user.email, 'from', casdoorAuth.isAuthenticated ? 'Casdoor' : 'legacy auth') + } + const queryClient = useQueryClient() const queryKeys = { diff --git a/ushadow/frontend/src/hooks/useMemories.ts b/ushadow/frontend/src/hooks/useMemories.ts index 5a59453c..66d1f25d 100644 --- a/ushadow/frontend/src/hooks/useMemories.ts +++ b/ushadow/frontend/src/hooks/useMemories.ts @@ -10,6 +10,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { memoriesApi, type MemorySource } from '../services/api' import { useMemoriesStore } from '../stores/memoriesStore' import { useAuth } from '../contexts/AuthContext' +import { useCasdoorAuth } from '../contexts/CasdoorAuthContext' import type { Memory } from '../types/memory' // Fallback user ID when not authenticated @@ -17,8 +18,26 @@ const FALLBACK_USER_ID = 'ushadow' export function useMemories(source: MemorySource = 'openmemory') { // Get user from auth context - use email as OpenMemory user_id - const { user } = useAuth() + const { user: legacyUser, isLoading: legacyLoading } = useAuth() + const { isAuthenticated: kcAuthenticated, user: kcUser, isLoading: kcLoading } = useCasdoorAuth() + + // Use Casdoor user if authenticated, otherwise use legacy user + const user = kcAuthenticated && kcUser ? kcUser : legacyUser const userId = user?.email || FALLBACK_USER_ID + const isLoadingUser = legacyLoading || kcLoading + + // Diagnostic logging for user email resolution + if (!isLoadingUser && !user) { + console.warn('[useMemories] No user object available from either auth context, falling back to:', FALLBACK_USER_ID) + console.warn('[useMemories] Auth state:', { + casdoorAuth: { isAuthenticated: kcAuthenticated, hasUser: !!kcUser }, + legacyAuth: { hasUser: !!legacyUser } + }) + } else if (!isLoadingUser && !user.email) { + console.warn('[useMemories] User object exists but email is missing:', { user, userId: FALLBACK_USER_ID }) + } else if (!isLoadingUser) { + console.log('[useMemories] Using user email as ID:', user.email, 'from', kcAuthenticated ? 'Casdoor' : 'legacy auth') + } const queryClient = useQueryClient() const { searchQuery, @@ -41,6 +60,7 @@ export function useMemories(source: MemorySource = 'openmemory') { queryKey: queryKeys.memories, queryFn: () => memoriesApi.fetchMemories(userId, searchQuery, currentPage, pageSize, filters, source), staleTime: 30000, // 30 seconds + enabled: !isLoadingUser && userId !== FALLBACK_USER_ID, // Wait for auth to finish loading and have actual user }) // Health check @@ -120,7 +140,11 @@ export function useMemories(source: MemorySource = 'openmemory') { * Hook for fetching a single memory */ export function useMemory(memoryId: string) { - const { user } = useAuth() + const { user: legacyUser } = useAuth() + const { isAuthenticated: kcAuthenticated, user: kcUser } = useCasdoorAuth() + + // Use Casdoor user if authenticated, otherwise use legacy user + const user = kcAuthenticated && kcUser ? kcUser : legacyUser const userId = user?.email || FALLBACK_USER_ID return useQuery({ @@ -134,7 +158,11 @@ export function useMemory(memoryId: string) { * Hook for fetching related memories */ export function useRelatedMemories(memoryId: string) { - const { user } = useAuth() + const { user: legacyUser } = useAuth() + const { isAuthenticated: kcAuthenticated, user: kcUser } = useCasdoorAuth() + + // Use Casdoor user if authenticated, otherwise use legacy user + const user = kcAuthenticated && kcUser ? kcUser : legacyUser const userId = user?.email || FALLBACK_USER_ID return useQuery({ diff --git a/ushadow/frontend/src/hooks/useProviderConfigs.ts b/ushadow/frontend/src/hooks/useProviderConfigs.ts index 691cf091..2cc7a2e7 100644 --- a/ushadow/frontend/src/hooks/useProviderConfigs.ts +++ b/ushadow/frontend/src/hooks/useProviderConfigs.ts @@ -81,8 +81,9 @@ function getConfigSummary(template: Template, config?: Record): str if (config) { for (const key of summaryKeys) { - if (config[key]) { - return String(config[key]) + const val = config[key] + if (typeof val === 'string' && val) { + return val } } } @@ -136,9 +137,11 @@ export function useProviderConfigs( const filteredInitial = useMemo(() => { if (!capability || !hasInitialData) return null - // Filter templates that provide this capability + // Filter templates that provide this capability - include provider templates + // and installed compose services (e.g., faster-whisper providing transcription) const capabilityTemplates = initialTemplates!.filter( - t => t.provides === capability && t.source === 'provider' + t => t.provides === capability && + (t.source === 'provider' || (t.source === 'compose' && (t.installed || t.running))) ) // Filter configs that are based on capability templates const templateIds = new Set(capabilityTemplates.map(t => t.id)) @@ -182,13 +185,15 @@ export function useProviderConfigs( try { // Fetch all templates and filter by capability const [templatesRes, configsRes] = await Promise.all([ - svcConfigsApi.getTemplates('provider'), + svcConfigsApi.getTemplates(), svcConfigsApi.getServiceConfigs(), ]) - // Filter templates that provide this capability + // Filter templates that provide this capability - include provider templates + // and installed compose services (e.g., faster-whisper providing transcription) const capabilityTemplates = templatesRes.data.filter( - t => t.provides === capability + t => t.provides === capability && + (t.source === 'provider' || (t.source === 'compose' && (t.installed || t.running))) ) // Filter configs that are based on capability templates @@ -228,8 +233,9 @@ export function useProviderConfigs( configSummary: getConfigSummary(t), })) - // Configs as "saved" - const saved: ProviderOption[] = configs.map(c => { + // Configs as "saved" — exclude placeholders (id === template_id are installed-but-unconfigured + // defaults, not real user configs; they're already represented by the template entry above) + const saved: ProviderOption[] = configs.filter(c => c.id !== c.template_id).map(c => { const template = templates.find(t => t.id === c.template_id) return { id: c.id, @@ -239,7 +245,7 @@ export function useProviderConfigs( templateId: c.template_id, mode: template?.mode, configured: true, // If it exists as a config, it's been configured - configSummary: undefined, // TODO: Load from config details if needed + configSummary: template ? getConfigSummary(template, c.config) : undefined, } }) @@ -279,9 +285,8 @@ export function useProviderConfigs( // Create wiring connection await svcConfigsApi.createWiring({ source_config_id: actualConfigId, - source_capability: capability!, target_config_id: targetId, - target_capability: targetCapability, + capability: capability!, }) // Refresh to get updated configs diff --git a/ushadow/frontend/src/hooks/useServiceCatalog.ts b/ushadow/frontend/src/hooks/useServiceCatalog.ts index a8b78fec..e6820300 100644 --- a/ushadow/frontend/src/hooks/useServiceCatalog.ts +++ b/ushadow/frontend/src/hooks/useServiceCatalog.ts @@ -54,15 +54,16 @@ export function useServiceCatalog(): UseServiceCatalogResult { mutationFn: async (serviceId: string) => { const serviceToInstall = catalogQuery.data?.find((s: any) => s.service_id === serviceId) - // If service has a wizard, navigate immediately and install in background - if (serviceToInstall?.wizard) { + // If service has a wizard AND still needs setup, navigate to wizard first. + // If already configured (needs_setup === false), just install directly. + if (serviceToInstall?.wizard && serviceToInstall?.needs_setup) { setIsOpen(false) navigate(`/wizard/${serviceToInstall.wizard}`) // Continue installation in background await servicesApi.install(serviceId) } else { - // For services without wizard, wait for installation + // No setup required (or no wizard) — install directly await servicesApi.install(serviceId) } }, diff --git a/ushadow/frontend/src/hooks/useServiceConfigData.ts b/ushadow/frontend/src/hooks/useServiceConfigData.ts index d7a192ce..6788317f 100644 --- a/ushadow/frontend/src/hooks/useServiceConfigData.ts +++ b/ushadow/frontend/src/hooks/useServiceConfigData.ts @@ -1,8 +1,16 @@ /** * useServiceConfigData - Fetch all service configuration data with React Query * - * Pattern 2: Data Fetching Hook - * Handles loading templates, instances, wiring, statuses, and deployments + * Split into three queries to unblock page rendering: + * + * 1. coreQuery (fast, blocks render) — templates (installed only), instances, wiring + * These are pure YAML reads on the backend; the page is ready as soon as they resolve. + * + * 2. statusQuery (slow, non-blocking) — Docker container statuses + * Docker API calls can be slow. Status updates the UI once ready without blocking render. + * + * 3. deploymentsQuery (non-blocking) — Slim deployment records (no env map) + * Fetched without deployed_config to reduce payload size. */ import { useQuery, useQueryClient } from '@tanstack/react-query' @@ -28,62 +36,93 @@ export interface UseServiceConfigDataResult { refresh: () => Promise } +const CORE_QUERY_KEY = ['service-configs-core'] as const +const STATUS_QUERY_KEY = ['service-statuses'] as const +const DEPLOYMENTS_QUERY_KEY = ['service-deployments'] as const + /** - * Fetch all service configuration data in a single query. - * Uses React Query for caching and automatic refetching. + * Fetch service configuration data. + * + * Core data (templates/instances/wiring) blocks page render. + * Status and deployments load in the background and update when ready. */ export function useServiceConfigData(): UseServiceConfigDataResult { - const query = useQuery({ - queryKey: ['service-configs'], + const queryClient = useQueryClient() + + // ── Fast core query — page renders when this resolves ─────────────────────── + const coreQuery = useQuery({ + queryKey: CORE_QUERY_KEY, queryFn: async () => { - console.log('🔄 Fetching service config data...') - const [templatesRes, instancesRes, wiringRes, statusesRes, deploymentsRes] = await Promise.all([ - svcConfigsApi.getTemplates(), + console.log('🔄 Fetching core service config data...') + const [templatesRes, instancesRes, wiringRes] = await Promise.all([ + svcConfigsApi.getTemplates({ installed: true }), svcConfigsApi.getServiceConfigs(), svcConfigsApi.getWiring(), - servicesApi.getAllStatuses().catch(() => ({ data: {} })), - deploymentsApi.listDeployments().catch((err) => { - console.error('Failed to load deployments:', err) - return { data: [] } - }), ]) - - const result = { + console.log('✅ Core data fetched:', { + templates: templatesRes.data.length, + instances: instancesRes.data.length, + wiring: wiringRes.data.length, + }) + return { templates: templatesRes.data, instances: instancesRes.data, wiring: wiringRes.data, - serviceStatuses: statusesRes.data || {}, - deployments: deploymentsRes.data || [], } + }, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + refetchOnWindowFocus: false, + retry: 1, + }) - console.log('✅ Service config data fetched:', { - templates: result.templates.length, - instances: result.instances.length, - wiring: result.wiring.length, - deployments: result.deployments.length, - }) + // ── Status query — non-blocking, updates when Docker responds ──────────────── + const statusQuery = useQuery({ + queryKey: STATUS_QUERY_KEY, + queryFn: async () => { + const res = await servicesApi.getAllStatuses().catch(() => ({ data: {} })) + return res.data || {} + }, + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + retry: false, + }) - return result + // ── Deployments query — slim (no env map), non-blocking ───────────────────── + const deploymentsQuery = useQuery({ + queryKey: DEPLOYMENTS_QUERY_KEY, + queryFn: async () => { + const res = await deploymentsApi.listDeployments({ slim: true }).catch((err) => { + console.error('Failed to load deployments:', err) + return { data: [] } + }) + return res.data || [] }, - staleTime: 0, // Never cache - always fetch fresh data - gcTime: 0, // Don't keep inactive data in cache - refetchOnWindowFocus: false, // Only refetch on manual refresh - retry: 1, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + refetchOnWindowFocus: false, + retry: false, }) const refresh = async () => { console.log('🔄 Manual refresh triggered') - await query.refetch() + await Promise.all([ + queryClient.refetchQueries({ queryKey: CORE_QUERY_KEY }), + queryClient.refetchQueries({ queryKey: STATUS_QUERY_KEY }), + queryClient.refetchQueries({ queryKey: DEPLOYMENTS_QUERY_KEY }), + ]) } return { - templates: query.data?.templates, - instances: query.data?.instances, - wiring: query.data?.wiring, - serviceStatuses: query.data?.serviceStatuses, - deployments: query.data?.deployments, - isLoading: query.isLoading, - error: query.error, + templates: coreQuery.data?.templates, + instances: coreQuery.data?.instances, + wiring: coreQuery.data?.wiring, + serviceStatuses: statusQuery.data, + deployments: deploymentsQuery.data, + // Only block render on core data — status/deployments are progressive + isLoading: coreQuery.isLoading, + error: coreQuery.error, refresh, } } diff --git a/ushadow/frontend/src/hooks/useShare.ts b/ushadow/frontend/src/hooks/useShare.ts new file mode 100644 index 00000000..3b3161a1 --- /dev/null +++ b/ushadow/frontend/src/hooks/useShare.ts @@ -0,0 +1,41 @@ +import { useState } from 'react' + +export interface UseShareOptions { + resourceType: 'conversation' | 'memory' | 'collection' + resourceId: string +} + +export interface UseShareReturn { + isOpen: boolean + onClose: () => void + openShareDialog: () => void + resourceType: 'conversation' | 'memory' | 'collection' + resourceId: string +} + +/** + * Hook for managing share dialog state. + * Returns props compatible with ShareDialog component. + * + * Usage: + * ```tsx + * const shareProps = useShare({ + * resourceType: 'conversation', + * resourceId: conversationId + * }) + * + * + * + * ``` + */ +export function useShare({ resourceType, resourceId }: UseShareOptions): UseShareReturn { + const [isOpen, setIsOpen] = useState(false) + + return { + isOpen, + onClose: () => setIsOpen(false), + openShareDialog: () => setIsOpen(true), + resourceType, + resourceId, + } +} diff --git a/ushadow/frontend/src/hooks/useWebRecording.ts b/ushadow/frontend/src/hooks/useWebRecording.ts index 9af6eba2..904c6605 100644 --- a/ushadow/frontend/src/hooks/useWebRecording.ts +++ b/ushadow/frontend/src/hooks/useWebRecording.ts @@ -30,6 +30,12 @@ export interface DebugStats { connectionAttempts: number } +export interface LiveTranscriptEntry { + source: string + finalText: string + interimText: string +} + export interface WebRecordingReturn { // Current state currentStep: RecordingStep @@ -57,6 +63,7 @@ export interface WebRecordingReturn { // For components analyser: AnalyserNode | null debugStats: DebugStats + liveTranscript: LiveTranscriptEntry[] // Utilities formatDuration: (seconds: number) => string @@ -80,6 +87,7 @@ export const useWebRecording = (): WebRecordingReturn => { // Destination selection state const [availableDestinations, setAvailableDestinations] = useState([]) const [selectedDestinationIds, setSelectedDestinationIds] = useState([]) + const destinationsFetchedRef = useRef(false) // Audio device selection state const [availableAudioDevices, setAvailableAudioDevices] = useState([]) @@ -104,8 +112,10 @@ export const useWebRecording = (): WebRecordingReturn => { const capabilities = typeof window !== 'undefined' ? getBrowserCapabilities() : { hasGetDisplayMedia: false } const canAccessDualStream = canAccessMicrophone && capabilities.hasGetDisplayMedia - // Fetch available destinations on mount + // Fetch available destinations on mount (ref guard prevents StrictMode double-fetch) useEffect(() => { + if (destinationsFetchedRef.current) return + destinationsFetchedRef.current = true const fetchDestinations = async () => { try { // Filter for audio_intake endpoints on the backend @@ -167,6 +177,10 @@ export const useWebRecording = (): WebRecordingReturn => { connectionAttempts: 0 }) + // Live transcript - accumulated text from destination services (e.g. Chronicle Deepgram interim results) + const transcriptBySourceRef = useRef>({}) + const [liveTranscript, setLiveTranscript] = useState([]) + // Refs for WebSocket adapter and legacy mode const adapterRef = useRef(null) const legacyWsRef = useRef(null) @@ -293,6 +307,8 @@ export const useWebRecording = (): WebRecordingReturn => { // Reset state chunkCountRef.current = 0 batchAudioChunksRef.current = [] + transcriptBySourceRef.current = {} + setLiveTranscript([]) setDebugStats(prev => ({ ...prev, chunksSent: 0, @@ -301,8 +317,12 @@ export const useWebRecording = (): WebRecordingReturn => { connectionAttempts: prev.connectionAttempts + 1 })) - // Chronicle uses unified auth with ushadow - same token works for both - const token = localStorage.getItem(getStorageKey('token')) + // Get auth token - prefer Casdoor token, fallback to legacy token + // This matches the pattern used in api.ts request interceptor + const kcToken = localStorage.getItem('kc_access_token') + const legacyToken = localStorage.getItem(getStorageKey('token')) + const token = kcToken || legacyToken + if (!token) { throw new Error('No authentication token found - please log in to ushadow') } @@ -311,25 +331,101 @@ export const useWebRecording = (): WebRecordingReturn => { // ===== DUAL-STREAM MODE ===== console.log('Starting dual-stream recording') - // Get Chronicle direct URL for WebSocket - const backendUrl = await getChronicleDirectUrl() + let displayStream: MediaStream | null = null + try { + // IMPORTANT: Request display media FIRST while still in user gesture context + // getDisplayMedia() must be called synchronously from a user gesture + // Doing ANY await before this call will cause the browser to block the picker + setLegacyStep('display') + console.log('🖥️ Step 1: Requesting display media (MUST be first for user gesture)') + displayStream = await navigator.mediaDevices.getDisplayMedia({ + audio: { + sampleRate: 16000, + channelCount: 1, + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false + }, + video: true // Required for picker - will be stopped immediately + }) + + // IMPORTANT: Don't stop/remove video tracks - this can end the audio track too! + // Instead, keep the video track running but we won't use it + // The browser requires video to be requested for getDisplayMedia to work properly + const videoTracks = displayStream.getVideoTracks() + console.log('🎬 Keeping', videoTracks.length, 'video tracks running (required for audio)') + + // Verify we got audio + const audioTracks = displayStream.getAudioTracks() + console.log('🔊 Display stream audio tracks:', audioTracks.length) + if (audioTracks.length > 0) { + console.log('🔊 Audio track details:', { + label: audioTracks[0].label, + enabled: audioTracks[0].enabled, + muted: audioTracks[0].muted, + readyState: audioTracks[0].readyState, + settings: audioTracks[0].getSettings() + }) + } - // Create and connect adapter - const adapter = new ChronicleWebSocketAdapter({ - backendUrl, - token, - deviceName: 'ushadow-dual-stream', - mode: 'dual-stream' - }) + if (audioTracks.length === 0) { + displayStream.getTracks().forEach(t => t.stop()) + throw new Error('No audio track found. When selecting a tab/window, make sure to CHECK the "Share tab audio" or "Share system audio" checkbox at the bottom of the picker!') + } - await adapter.connect() - adapterRef.current = adapter + // Now that we have display permission, do other async operations + // Use selected destinations from state (like streaming mode) + const destinations: ExposedUrl[] = availableDestinations.filter(d => + selectedDestinationIds.includes(d.instance_id) + ) - // Send audio-start - await adapter.sendAudioStart('dual-stream') + if (destinations.length === 0) { + displayStream.getTracks().forEach(t => t.stop()) + throw new Error('No audio destinations selected. Please select at least one destination to record.') + } - // Start dual-stream recording - await dualStream.startRecording('dual-stream') + console.log('Using selected audio destinations:', destinations.map(d => d.instance_name)) + + // Build relay WebSocket URL (use relay instead of direct connection) + const relayDestinations = destinations.map(dest => ({ + name: dest.instance_name, + url: getAudioPath(dest.url) + })) + + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const relayBaseUrl = BACKEND_URL ? BACKEND_URL.replace(/^https?:/, wsProtocol) : `${wsProtocol}//${window.location.host}` + const destinationsParam = encodeURIComponent(JSON.stringify(relayDestinations)) + const tokenParam = encodeURIComponent(token) + const backendUrl = `${relayBaseUrl}/ws/audio/relay?destinations=${destinationsParam}&token=${tokenParam}` + + console.log('Dual-stream connecting via relay:', backendUrl.replace(token, 'REDACTED')) + + // Create and connect adapter (will use relay instead of direct connection) + const adapter = new ChronicleWebSocketAdapter({ + backendUrl, + token, + deviceName: 'ushadow-dual-stream', + mode: 'dual-stream' + }) + + await adapter.connect() + adapterRef.current = adapter + + // Send audio-start + await adapter.sendAudioStart('dual-stream') + + // Start dual-stream recording (will request microphone internally) + console.log('🎙️ Step 3: Starting dual-stream recording (will request microphone)...') + await dualStream.startRecording('dual-stream', displayStream) + console.log('✅ Dual-stream recording started successfully') + } catch (error) { + // Cleanup display stream if it was captured + if (displayStream) { + console.error('❌ Dual-stream setup failed, cleaning up display stream:', error) + displayStream.getTracks().forEach(t => t.stop()) + } + throw error // Re-throw to be caught by outer try-catch + } // Start duration timer durationIntervalRef.current = setInterval(() => { @@ -412,7 +508,23 @@ export const useWebRecording = (): WebRecordingReturn => { socket.onmessage = (event) => { setDebugStats(prev => ({ ...prev, messagesReceived: prev.messagesReceived + 1 })) - console.log('Message from audio relay:', event.data) + try { + const msg = JSON.parse(event.data) + if (msg.type === 'interim_transcript' && msg.source && msg.data?.text !== undefined) { + const source = msg.source as string + const text = msg.data.text as string + const isFinal = !!(msg.data.is_final || msg.data.speech_final) + const current = transcriptBySourceRef.current[source] || { finalText: '', interimText: '' } + transcriptBySourceRef.current[source] = isFinal + ? { finalText: current.finalText + (text ? text + ' ' : ''), interimText: '' } + : { ...current, interimText: text } + setLiveTranscript( + Object.entries(transcriptBySourceRef.current).map(([src, entry]) => ({ source: src, ...entry })) + ) + } + } catch { + // Non-JSON messages ignored + } } }) @@ -526,6 +638,9 @@ export const useWebRecording = (): WebRecordingReturn => { // Cleanup dual-stream adapterRef.current?.close() adapterRef.current = null + // Set error state for dual-stream + setLegacyStep('error') + setLegacyError(error instanceof Error ? error.message : 'Dual-stream recording failed') } else { setLegacyStep('error') setLegacyError(error instanceof Error ? error.message : 'Recording failed') @@ -639,9 +754,9 @@ export const useWebRecording = (): WebRecordingReturn => { const recordingDuration = isDualStream ? (dualStream.isRecording ? legacyDuration : 0) : legacyDuration const error = isDualStream ? (dualStream.error?.message || null) : legacyError - // Get analyser - for dual-stream, try to get from mixer + // Get analyser - for dual-stream, get the mixed output analyser const analyser = isDualStream - ? dualStream.getAnalyser('microphone') + ? dualStream.getAnalyser('mixed') : legacyAnalyser return { @@ -662,6 +777,7 @@ export const useWebRecording = (): WebRecordingReturn => { setMode, analyser, debugStats, + liveTranscript, formatDuration, canAccessMicrophone, canAccessDualStream diff --git a/ushadow/frontend/src/hooks/useWiringActions.ts b/ushadow/frontend/src/hooks/useWiringActions.ts index 3c1ebe1d..ff6f53e6 100644 --- a/ushadow/frontend/src/hooks/useWiringActions.ts +++ b/ushadow/frontend/src/hooks/useWiringActions.ts @@ -85,7 +85,7 @@ export function useWiringActions( for (const w of wiring) { // Key: targetId::capability - const targetKey = `${w.target_config_id}::${w.target_capability}` + const targetKey = `${w.target_config_id}::${w.capability}` byTarget.set(targetKey, w) // Source connections @@ -140,15 +140,14 @@ export function useWiringActions( // Check for existing connection and remove it first const existing = getConnection(targetConfigId, capability) if (existing) { - await svcConfigsApi.deleteWiring(existing.id) + await svcConfigsApi.deleteWiring(existing.target_config_id, existing.capability) } // Create new wiring const request: WiringCreateRequest = { source_config_id: sourceConfigId, - source_capability: capability, target_config_id: targetConfigId, - target_capability: capability, + capability, } const result = await svcConfigsApi.createWiring(request) @@ -161,10 +160,14 @@ export function useWiringActions( [getConnection, onUpdate] ) - // Remove wiring by ID + // Remove wiring by ID (now accepts target+capability instead of synthetic ID) const unwire = useCallback( async (wiringId: string): Promise => { - await svcConfigsApi.deleteWiring(wiringId) + // wiringId format: "{target_config_id}-{capability}" — split on last '-' + const lastDash = wiringId.lastIndexOf('-') + const targetConfigId = wiringId.substring(0, lastDash) + const capability = wiringId.substring(lastDash + 1) + await svcConfigsApi.deleteWiring(targetConfigId, capability) await onUpdate() }, [onUpdate] @@ -178,7 +181,7 @@ export function useWiringActions( return false } - await svcConfigsApi.deleteWiring(existing.id) + await svcConfigsApi.deleteWiring(existing.target_config_id, existing.capability) await onUpdate() return true }, diff --git a/ushadow/frontend/src/main.tsx b/ushadow/frontend/src/main.tsx index abc2d276..9df45208 100644 --- a/ushadow/frontend/src/main.tsx +++ b/ushadow/frontend/src/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import App from './App.tsx' import './index.css' +import '@mantine/core/styles.css' const queryClient = new QueryClient({ defaultOptions: { diff --git a/ushadow/frontend/src/modules/dual-stream-audio/adapters/chronicleAdapter.ts b/ushadow/frontend/src/modules/dual-stream-audio/adapters/chronicleAdapter.ts index 828b8804..a9407a0e 100644 --- a/ushadow/frontend/src/modules/dual-stream-audio/adapters/chronicleAdapter.ts +++ b/ushadow/frontend/src/modules/dual-stream-audio/adapters/chronicleAdapter.ts @@ -35,10 +35,14 @@ export class ChronicleWebSocketAdapter { const { backendUrl, token, deviceName = 'webui-dual-stream' } = this.config // Build WebSocket URL - // Determine protocol from the backend URL, not the current page let wsUrl: string - if (backendUrl && backendUrl.startsWith('http')) { + // Check if backendUrl is already a complete WebSocket URL (relay or direct) + if (backendUrl && (backendUrl.startsWith('ws://') || backendUrl.startsWith('wss://'))) { + // Already a complete WebSocket URL (e.g., from relay) + wsUrl = backendUrl + console.log('🔗 Using pre-built WebSocket URL (relay)') + } else if (backendUrl && backendUrl.startsWith('http')) { // Extract protocol from backendUrl (https:// -> wss://, http:// -> ws://) const protocol = backendUrl.startsWith('https://') ? 'wss:' : 'ws:' const host = backendUrl.replace(/^https?:\/\//, '') @@ -53,7 +57,7 @@ export class ChronicleWebSocketAdapter { wsUrl = `${protocol}//${window.location.host}/ws_pcm?token=${token}&device_name=${deviceName}` } - console.log('🔗 Connecting to Chronicle WebSocket:', wsUrl) + console.log('🔗 Connecting to Chronicle WebSocket:', wsUrl.replace(/token=[^&]+/, 'token=REDACTED')) this.ws = new WebSocket(wsUrl) diff --git a/ushadow/frontend/src/modules/dual-stream-audio/core/audioMixer.ts b/ushadow/frontend/src/modules/dual-stream-audio/core/audioMixer.ts index ec8d8281..d8461126 100644 --- a/ushadow/frontend/src/modules/dual-stream-audio/core/audioMixer.ts +++ b/ushadow/frontend/src/modules/dual-stream-audio/core/audioMixer.ts @@ -21,12 +21,14 @@ export class AudioStreamMixer { private streams: Map private merger: ChannelMergerNode | null private destination: MediaStreamAudioDestinationNode | null + private mixedAnalyser: AnalyserNode | null constructor(sampleRate: number = 16000) { this.audioContext = new AudioContext({ sampleRate }) this.streams = new Map() this.merger = null this.destination = null + this.mixedAnalyser = null } /** @@ -41,11 +43,15 @@ export class AudioStreamMixer { // Create merger node (supports up to 6 inputs by default) this.merger = this.audioContext.createChannelMerger(2) + // Create analyser for mixed output (for visualization) + this.mixedAnalyser = createAnalyser(this.audioContext, 256) + // Create destination for mixed output this.destination = this.audioContext.createMediaStreamDestination() - // Connect merger to destination - this.merger.connect(this.destination) + // Connect: merger → mixedAnalyser → destination + this.merger.connect(this.mixedAnalyser) + this.mixedAnalyser.connect(this.destination) } /** @@ -164,6 +170,13 @@ export class AudioStreamMixer { return null } + /** + * Get analyser for the mixed output (for visualization) + */ + getMixedAnalyser(): AnalyserNode | null { + return this.mixedAnalyser + } + /** * Get the mixed output stream */ @@ -222,15 +235,17 @@ export class AudioStreamMixer { this.removeStream(streamId) } - // Disconnect merger and destination + // Disconnect merger, analyser, and destination try { this.merger?.disconnect() + this.mixedAnalyser?.disconnect() this.destination?.disconnect() } catch (error) { console.warn('Error disconnecting mixer nodes:', error) } this.merger = null + this.mixedAnalyser = null this.destination = null // Close audio context diff --git a/ushadow/frontend/src/modules/dual-stream-audio/core/types.ts b/ushadow/frontend/src/modules/dual-stream-audio/core/types.ts index 2e55ca8f..a35e22a8 100644 --- a/ushadow/frontend/src/modules/dual-stream-audio/core/types.ts +++ b/ushadow/frontend/src/modules/dual-stream-audio/core/types.ts @@ -162,11 +162,11 @@ export interface DualStreamRecordingHook { activeStreams: StreamInfo[] // Controls - startRecording: (mode: RecordingMode) => Promise + startRecording: (mode: RecordingMode, preCapturedDisplayStream?: MediaStream) => Promise stopRecording: () => void setStreamGain: (streamId: string, gain: number) => void // Utilities formatDuration: (seconds: number) => string - getAnalyser: (streamType: StreamType) => AnalyserNode | null + getAnalyser: (streamType: StreamType | 'mixed') => AnalyserNode | null } diff --git a/ushadow/frontend/src/modules/dual-stream-audio/hooks/useDualStreamRecording.ts b/ushadow/frontend/src/modules/dual-stream-audio/hooks/useDualStreamRecording.ts index dba88e3e..12a3bc77 100644 --- a/ushadow/frontend/src/modules/dual-stream-audio/hooks/useDualStreamRecording.ts +++ b/ushadow/frontend/src/modules/dual-stream-audio/hooks/useDualStreamRecording.ts @@ -97,9 +97,14 @@ export function useDualStreamRecording( /** * Start recording + * + * @param recordingMode - The recording mode ('microphone-only' or 'dual-stream') + * @param preCaptur edDisplayStream - Optional pre-captured display stream (for dual-stream mode) + * This is important for browser security: getDisplayMedia() + * must be called from a user gesture, so we capture it early */ const startRecording = useCallback( - async (recordingMode: RecordingMode) => { + async (recordingMode: RecordingMode, preCapturedDisplayStream?: MediaStream) => { try { // Check browser compatibility const capabilities = getBrowserCapabilities() @@ -136,10 +141,29 @@ export function useDualStreamRecording( // Step 2: Capture display media (if dual-stream mode) let displayStream: MediaStream | null = null if (recordingMode === 'dual-stream') { - setState('requesting-display') - console.log('🖥️ Step 2: Capturing display media...') + if (preCapturedDisplayStream) { + // Use pre-captured stream (already requested in user gesture context) + console.log('🖥️ Step 2: Using pre-captured display stream') + displayStream = preCapturedDisplayStream + + // Log stream details + const audioTracks = displayStream.getAudioTracks() + const videoTracks = displayStream.getVideoTracks() + console.log('📊 Pre-captured stream status:', { + audioTracks: audioTracks.length, + videoTracks: videoTracks.length, + audioEnabled: audioTracks[0]?.enabled, + audioMuted: audioTracks[0]?.muted, + audioReadyState: audioTracks[0]?.readyState, + audioLabel: audioTracks[0]?.label + }) + } else { + // Fallback: capture display media now (may fail if not in user gesture context) + setState('requesting-display') + console.log('🖥️ Step 2: Capturing display media...') + displayStream = await captureDisplayMedia(config.displayConstraints?.audio) + } - displayStream = await captureDisplayMedia(config.displayConstraints?.audio) displayStreamRef.current = displayStream // Monitor display stream for ended event @@ -160,18 +184,33 @@ export function useDualStreamRecording( // Add streams to mixer const micStreamId = mixer.addStream(micStream, 'microphone', 1.0) + console.log('✅ Added microphone stream to mixer:', micStreamId) const streamIds: string[] = [micStreamId] const streamTypes: Array<'microphone' | 'display'> = ['microphone'] if (displayStream) { + console.log('➕ Adding display stream to mixer...') + const displayAudioTracks = displayStream.getAudioTracks() + console.log('📊 Display stream before adding to mixer:', { + audioTracks: displayAudioTracks.length, + audioEnabled: displayAudioTracks[0]?.enabled, + audioMuted: displayAudioTracks[0]?.muted, + audioReadyState: displayAudioTracks[0]?.readyState + }) + const displayStreamId = mixer.addStream(displayStream, 'display', 1.0) + console.log('✅ Added display stream to mixer:', displayStreamId) streamIds.push(displayStreamId) streamTypes.push('display') + } else { + console.warn('⚠️ No display stream to add to mixer') } // Update active streams - setActiveStreams(mixer.getActiveStreams()) + const activeStreams = mixer.getActiveStreams() + console.log('📊 Active streams in mixer:', activeStreams) + setActiveStreams(activeStreams) // Get mixed output stream const mixedStream = mixer.getMixedStream() @@ -283,11 +322,14 @@ export function useDualStreamRecording( }, []) /** - * Get analyser for a stream type + * Get analyser for a stream type (or 'mixed' for the mixed output) */ const getAnalyser = useCallback( - (streamType: 'microphone' | 'display'): AnalyserNode | null => { + (streamType: 'microphone' | 'display' | 'mixed'): AnalyserNode | null => { if (!mixerRef.current) return null + if (streamType === 'mixed') { + return mixerRef.current.getMixedAnalyser() + } return mixerRef.current.getAnalyserByType(streamType) }, [] diff --git a/ushadow/frontend/src/pages/ChatPage.tsx b/ushadow/frontend/src/pages/ChatPage.tsx index ba7aa6ff..578ad089 100644 --- a/ushadow/frontend/src/pages/ChatPage.tsx +++ b/ushadow/frontend/src/pages/ChatPage.tsx @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom' import { useTheme } from '../contexts/ThemeContext' import { chatApi, BACKEND_URL } from '../services/api' import type { ChatMessage, ChatStatus } from '../services/api' +import { getStorageKey } from '../utils/storage' interface Message extends ChatMessage { id: string @@ -97,12 +98,23 @@ export default function ChatPage() { content: m.content, })) + // Get token from storage (Casdoor/OIDC in localStorage, or legacy token) + const kcToken = sessionStorage.getItem('kc_access_token') + const legacyToken = localStorage.getItem(getStorageKey('token')) + const token = kcToken || legacyToken + + // Build headers - only include Authorization if we have a valid token + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (token && token !== 'null' && token !== 'undefined') { + headers['Authorization'] = `Bearer ${token}` + } + const response = await fetch(`${BACKEND_URL}/api/chat`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('ushadow_token')}`, - }, + headers, body: JSON.stringify({ messages: allMessages, use_memory: useMemory, diff --git a/ushadow/frontend/src/pages/ClusterPage.tsx b/ushadow/frontend/src/pages/ClusterPage.tsx index 609c2a95..558ad19f 100644 --- a/ushadow/frontend/src/pages/ClusterPage.tsx +++ b/ushadow/frontend/src/pages/ClusterPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { createPortal } from 'react-dom' -import { Server, Plus, RefreshCw, Copy, Trash2, CheckCircle, XCircle, Clock, Monitor, HardDrive, Cpu, Check, Play, Square, RotateCcw, Package, FileText, ArrowUpCircle, X, Unlink, ExternalLink, AlertTriangle, QrCode, Smartphone, Info } from 'lucide-react' +import { Server, Plus, RefreshCw, Copy, Trash2, CheckCircle, XCircle, Clock, Monitor, HardDrive, Cpu, Check, Play, Square, RotateCcw, Package, FileText, ArrowUpCircle, X, Unlink, ExternalLink, AlertTriangle, QrCode, Smartphone, Info, Globe } from 'lucide-react' import { clusterApi, deploymentsApi, servicesApi, Deployment } from '../services/api' import { useMobileQrCode } from '../hooks/useQrCode' import Modal from '../components/Modal' @@ -18,6 +18,7 @@ interface CatalogService { interface UNode { id: string hostname: string + envname?: string display_name: string role: 'leader' | 'worker' | 'standby' platform: string @@ -27,12 +28,24 @@ interface UNode { registered_at: string manager_version: string services: string[] + labels?: Record capabilities: { can_run_docker: boolean can_run_gpu: boolean available_memory_mb: number available_cpu_cores: number available_disk_gb: number + gpu_count?: number + gpu_devices?: Array<{ + vendor: string + index: number + model: string + vram_mb?: number + cuda_version?: string + rocm_version?: string + }> + gpu_model?: string + gpu_vram_mb?: number } metadata?: { last_metrics?: { @@ -69,6 +82,8 @@ interface DiscoveredPeer { // Leader info from /api/unodes/leader/info interface LeaderInfo { hostname: string + envname?: string + display_name?: string tailscale_ip: string capabilities: { can_run_docker: boolean @@ -84,6 +99,8 @@ interface LeaderInfo { unodes: Array<{ id: string hostname: string + envname?: string + display_name?: string tailscale_ip: string status: string role: string @@ -96,6 +113,9 @@ interface LeaderInfo { available_memory_mb: number available_cpu_cores: number available_disk_gb: number + gpu_count?: number + gpu_model?: string + gpu_vram_mb?: number } services?: string[] manager_version?: string @@ -152,6 +172,12 @@ export default function ClusterPage() { const [upgradeVersion, setUpgradeVersion] = useState('latest') const [upgrading, setUpgrading] = useState(false) const [upgradeResult, setUpgradeResult] = useState<{ success: boolean; message: string } | null>(null) + + // Public unode creation state + const [showPublicUnodeModal, setShowPublicUnodeModal] = useState(false) + const [creatingPublicUnode, setCreatingPublicUnode] = useState(false) + const [publicUnodeResult, setPublicUnodeResult] = useState<{ success: boolean; message: string; hostname?: string; public_url?: string } | null>(null) + const [tailscaleAuthKey, setTailscaleAuthKey] = useState('') const [availableVersions, setAvailableVersions] = useState(['latest']) const [loadingVersions, setLoadingVersions] = useState(false) @@ -255,6 +281,38 @@ export default function ClusterPage() { } } + const handleCreatePublicUnode = async () => { + if (!tailscaleAuthKey.trim()) { + alert('Please enter a Tailscale auth key') + return + } + + try { + setCreatingPublicUnode(true) + setPublicUnodeResult(null) + const response = await clusterApi.createPublicUNode({ + tailscale_auth_key: tailscaleAuthKey, + labels: { zone: 'public', funnel: 'enabled' } + }) + setPublicUnodeResult({ + success: response.data.success, + message: response.data.message, + hostname: response.data.hostname, + public_url: response.data.public_url + }) + // Reload unodes after a brief delay to show the new unode + setTimeout(() => loadUnodes(), 5000) + } catch (err: any) { + console.error('Error creating public unode:', err) + setPublicUnodeResult({ + success: false, + message: err.response?.data?.detail || err.message + }) + } finally { + setCreatingPublicUnode(false) + } + } + const handleRemoveNode = async (hostname: string) => { if (!confirm(`Remove ${hostname} from the cluster?`)) return try { @@ -502,6 +560,14 @@ export default function ClusterPage() { Upgrade All )} + +
+ + {/* Body */} +
+
+

+ This will create a virtual public worker unode on the same physical machine with Tailscale Funnel enabled. + It can host services that need public internet access (like share-dmz). +

+
+ + {!publicUnodeResult && ( + <> +
+ + setTailscaleAuthKey(e.target.value)} + placeholder="tskey-auth-..." + className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100" + data-testid="tailscale-auth-key-input" + /> +

+ Generate a key at{' '} + + Tailscale Settings + + {' '}with tags: tag:dmz,tag:public +

+
+ +
+
+ +
+

Important:

+
    +
  • This unode will be publicly accessible via Tailscale Funnel
  • +
  • Only deploy DMZ services with appropriate security
  • +
  • Services deployed here can be reached from the internet
  • +
+
+
+
+ + )} + + {publicUnodeResult && ( +
+
+ {publicUnodeResult.success ? ( + + ) : ( + + )} +
+

{publicUnodeResult.message}

+ {publicUnodeResult.hostname && ( +

+ Hostname: {publicUnodeResult.hostname} +

+ )} + {publicUnodeResult.public_url && ( +

+ Public URL: {publicUnodeResult.public_url} +

+ )} +
+
+
+ )} +
+ + {/* Footer */} +
+ + {!publicUnodeResult && ( + + )} +
+
+
, + document.body + )} + {/* Deploy Service Modal */} {showDeployModal && selectedNode && createPortal(
() + const [searchParams] = useSearchParams() + const navigate = useNavigate() + const source = (searchParams.get('source') || 'chronicle') as ConversationSource + + const { conversation, isLoading, error } = useConversationDetail(id!, source) + + // Share functionality + const shareProps = useShare({ + resourceType: 'conversation', + resourceId: id || '', + }) + + // Fetch memories for this conversation (unified API for both Chronicle and Mycelia) + const { data: memoriesData, isLoading: memoriesLoading } = useQuery({ + queryKey: ['conversation-memories', id, source], + queryFn: async () => { + if (id && (source === 'chronicle' || source === 'mycelia')) { + const response = await unifiedMemoriesApi.getConversationMemories(id, source) + return response.data + } + return null + }, + enabled: (source === 'chronicle' || source === 'mycelia') && !!id, + }) + + // Audio playback state + const [playingSegment, setPlayingSegment] = useState(null) + const [playingFullAudio, setPlayingFullAudio] = useState(false) + const audioRef = useRef(null) + const segmentTimerRef = useRef(null) + + // Log for debugging + console.log('[ConversationDetailPage] Source:', source) + console.log('[ConversationDetailPage] Conversation:', conversation) + console.log('[ConversationDetailPage] Segments:', conversation?.segments) + + // Cleanup audio on unmount + useEffect(() => { + return () => { + if (audioRef.current) { + audioRef.current.pause() + } + if (segmentTimerRef.current) { + window.clearTimeout(segmentTimerRef.current) + } + } + }, []) + + // Handle segment play/pause + const handleSegmentPlayPause = async (segmentIndex: number, segment: any) => { + const segmentId = `segment-${segmentIndex}` + + // If this segment is playing, pause it + if (playingSegment === segmentId) { + if (audioRef.current) { + audioRef.current.pause() + } + if (segmentTimerRef.current) { + window.clearTimeout(segmentTimerRef.current) + segmentTimerRef.current = null + } + setPlayingSegment(null) + return + } + + // Stop any currently playing segment + if (audioRef.current) { + audioRef.current.pause() + } + if (segmentTimerRef.current) { + window.clearTimeout(segmentTimerRef.current) + segmentTimerRef.current = null + } + + try { + // Create or reuse audio element + if (!audioRef.current) { + audioRef.current = new Audio() + audioRef.current.addEventListener('ended', () => setPlayingSegment(null)) + } + + if (source === 'chronicle') { + // Chronicle: Use URL directly for instant playback (token in query string) + const audioUrl = await getChronicleAudioUrl(id!, true) + audioRef.current.src = audioUrl + audioRef.current.currentTime = segment.start + } else { + // Mycelia: Fetch as blob to include auth headers + const myceliaBackendUrl = '/api/services/mycelia-backend/proxy' + const myceliaConv = conversation as any + + // Get conversation start time from timeRanges + const conversationStart = myceliaConv?.timeRanges?.[0]?.start + if (!conversationStart) { + console.error('[ConversationDetail] No conversation start time found') + return + } + + // Calculate absolute timestamps for the segment + const convStartTime = new Date(conversationStart).getTime() + const segmentStartTime = convStartTime + (segment.start * 1000) + const segmentEndTime = convStartTime + (segment.end * 1000) + + // Convert to Unix timestamps (seconds) + // Use floor for start, ceil for end to avoid cutting off audio + const startUnix = Math.floor(segmentStartTime / 1000) + const endUnix = Math.ceil(segmentEndTime / 1000) + + const audioUrl = `${myceliaBackendUrl}/api/audio/stream?start=${startUnix}&end=${endUnix}` + + // Fetch with auth headers via axios + const response = await api.get(audioUrl, { responseType: 'blob' }) + const audioBlob = response.data + const objectUrl = URL.createObjectURL(audioBlob) + + // Clean up old object URL + if (audioRef.current.src.startsWith('blob:')) { + URL.revokeObjectURL(audioRef.current.src) + } + + audioRef.current.src = objectUrl + } + + await audioRef.current.play() + setPlayingSegment(segmentId) + + // Set timer to stop at segment end (only needed for Chronicle) + // For Mycelia, we fetch exact chunks so the 'ended' event handles it + if (source === 'chronicle') { + const duration = (segment.end - segment.start) * 1000 + segmentTimerRef.current = window.setTimeout(() => { + if (audioRef.current) { + audioRef.current.pause() + } + setPlayingSegment(null) + segmentTimerRef.current = null + }, duration) + } + } catch (err) { + console.error('[ConversationDetail] Error playing audio segment:', err) + setPlayingSegment(null) + } + } + + // Handle full conversation audio play/pause + const handleFullAudioPlayPause = async () => { + // If full audio is playing, pause it + if (playingFullAudio) { + if (audioRef.current) { + audioRef.current.pause() + } + setPlayingFullAudio(false) + return + } + + // Stop any segment that's playing + if (playingSegment) { + if (audioRef.current) { + audioRef.current.pause() + } + if (segmentTimerRef.current) { + window.clearTimeout(segmentTimerRef.current) + segmentTimerRef.current = null + } + setPlayingSegment(null) + } + + try { + // Create or reuse audio element + if (!audioRef.current) { + audioRef.current = new Audio() + audioRef.current.addEventListener('ended', () => setPlayingFullAudio(false)) + } + + if (source === 'chronicle') { + // Chronicle: Use URL directly for instant playback (token in query string) + const audioUrl = await getChronicleAudioUrl(id!, true) + audioRef.current.src = audioUrl + } else { + // Mycelia: Fetch as blob to include auth headers + const myceliaConv = conversation as any + const conversationStart = myceliaConv?.timeRanges?.[0]?.start + const conversationEnd = myceliaConv?.timeRanges?.[0]?.end + + if (!conversationStart || !conversationEnd) { + console.error('[ConversationDetail] No conversation time range found') + return + } + + // Convert to Unix timestamps (seconds) + // Use floor for start, ceil for end to avoid cutting off audio + const startUnix = Math.floor(new Date(conversationStart).getTime() / 1000) + const endUnix = Math.ceil(new Date(conversationEnd).getTime() / 1000) + + const myceliaBackendUrl = '/api/services/mycelia-backend/proxy' + const audioUrl = `${myceliaBackendUrl}/api/audio/stream?start=${startUnix}&end=${endUnix}` + + // Fetch with auth headers via axios + const response = await api.get(audioUrl, { responseType: 'blob' }) + const audioBlob = response.data + const objectUrl = URL.createObjectURL(audioBlob) + + // Clean up old object URL + if (audioRef.current.src.startsWith('blob:')) { + URL.revokeObjectURL(audioRef.current.src) + } + + audioRef.current.src = objectUrl + } + + audioRef.current.currentTime = 0 + await audioRef.current.play() + setPlayingFullAudio(true) + } catch (err) { + console.error('[ConversationDetail] Error playing full audio:', err) + setPlayingFullAudio(false) + } + } + + if (isLoading) { + return ( +
+
+
+

Loading conversation...

+
+
+ ) + } + + if (error || !conversation) { + return ( +
+ + +
+
+ +
+

+ Failed to load conversation +

+

+ {error ? String(error) : 'Conversation not found'} +

+
+
+
+
+ ) + } + + // Mycelia stores data differently than Chronicle + const myceliaConv = conversation as any + + // Extract title (mycelia uses 'name', chronicle uses 'title') + const title = conversation.title || myceliaConv?.name || 'Untitled Conversation' + + // Extract summary (mycelia uses summaries array, chronicle uses summary string) + const summary = conversation.summary || + (myceliaConv?.summaries && myceliaConv.summaries.length > 0 + ? myceliaConv.summaries[0].text + : null) + + // Extract detailed summary (mycelia uses 'details', chronicle uses 'detailed_summary') + const detailedSummary = conversation.detailed_summary || myceliaConv?.details + + // Check if segments exist (match Chronicle page logic) + const hasValidSegments = conversation.segments && conversation.segments.length > 0 + + // Extract start/end times + // For Mycelia: use started_at (actual recording start) over created_at (processing time) + const startTime = myceliaConv?.started_at || myceliaConv?.timeRanges?.[0]?.start || conversation.created_at + const endTime = myceliaConv?.completed_at || myceliaConv?.timeRanges?.[0]?.end + + // Format duration + const formatDuration = (seconds?: number) => { + if (!seconds) { + // Mycelia stores timeRanges instead of duration_seconds + if (myceliaConv?.timeRanges && myceliaConv.timeRanges.length > 0) { + const range = myceliaConv.timeRanges[0] + if (range.start && range.end) { + const start = new Date(range.start).getTime() + const end = new Date(range.end).getTime() + const durationMs = end - start + const durationSec = Math.floor(durationMs / 1000) + const mins = Math.floor(durationSec / 60) + const secs = durationSec % 60 + return `${mins}m ${secs}s` + } + } + return 'Unknown' + } + const mins = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${mins}m ${secs}s` + } + + const sourceColor = source === 'chronicle' ? 'blue' : 'purple' + const sourceLabel = source === 'chronicle' ? 'Chronicle' : 'Mycelia' + + return ( +
+ {/* Header with back button */} +
+ + + {/* Source badge */} + + {sourceLabel} + +
+ + {/* Conversation metadata */} +
+
+ +
+

+ {title} +

+ {summary && ( +
+ + {summary} + +
+ )} + {detailedSummary && detailedSummary !== summary && ( +
+ + {detailedSummary} + +
+ )} +
+
+ + {/* Play Full Audio Button and Share Button */} +
+ + + +
+ + {/* Share Dialog */} + + + + {/* Metadata grid */} +
+ {/* Start time */} + {startTime && ( +
+ +
+

+ {source === 'mycelia' ? 'Started' : 'Created'} +

+

+ {new Date(startTime).toLocaleString()} +

+
+
+ )} + + {/* End time */} + {endTime && ( +
+ +
+

+ {source === 'mycelia' ? 'Ended' : 'Completed'} +

+

+ {new Date(endTime).toLocaleString()} +

+
+
+ )} + +
+ +
+

Duration

+

+ {formatDuration(conversation.duration_seconds)} +

+
+
+ +
+ +
+

Segments

+

+ {hasValidSegments ? (conversation.segments?.length || 0) : 0} +

+
+
+ +
+ +
+

Memories

+

+ {conversation.memory_count || 0} +

+
+
+
+
+ + {/* Memories Section (Chronicle only) */} + {source === 'chronicle' && ( +
+
+
+ +

+ Memories +

+ {memoriesData && ( + + {memoriesData.count} + + )} +
+ {memoriesData && memoriesData.count > 0 && ( + + )} +
+ + {memoriesLoading ? ( +
+
+
+ ) : memoriesData && memoriesData.memories && memoriesData.memories.length > 0 ? ( +
+ {memoriesData.memories.map((memory, idx: number) => ( + navigate(`/memories/${memory.id}`)} + showSource={true} + testId={`memory-item-${idx}`} + /> + ))} +
+ ) : ( +
+ +

No memories extracted from this conversation

+
+ )} +
+ )} + + {/* Transcript */} + {hasValidSegments || conversation.transcript ? ( +
+

+ Transcript +

+ + {/* Segmented transcript (only if segments have actual text) */} + {hasValidSegments ? ( +
+ {conversation.segments!.map((segment, idx) => { + const segmentId = `segment-${idx}` + const isPlaying = playingSegment === segmentId + + return ( +
+
+
+ + {segment.speaker?.charAt(0)?.toUpperCase() || '?'} + +
+
+
+
+
+ + {segment.speaker || 'Unknown'} + + + {Math.floor(segment.start)}s - {Math.floor(segment.end)}s + +
+ +
+
+ + {segment.text} + +
+
+
+ ) + })} +
+ ) : ( + /* Plain transcript */ +
+ + {conversation.transcript} + +
+ )} +
+ ) : ( +
+ +

No transcript available

+
+ )} +
+ ) +} diff --git a/ushadow/frontend/src/pages/ConversationsPage.tsx b/ushadow/frontend/src/pages/ConversationsPage.tsx new file mode 100644 index 00000000..0fc67f38 --- /dev/null +++ b/ushadow/frontend/src/pages/ConversationsPage.tsx @@ -0,0 +1,191 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { MessageSquare, RefreshCw, AlertCircle } from 'lucide-react' +import { useMultiSourceConversations, type ConversationSource } from '../hooks/useConversations' +import ConversationCard from '../components/conversations/ConversationCard' + +// Available conversation sources +const SOURCES: Array<{ id: ConversationSource; label: string; color: string }> = [ + { id: 'chronicle', label: 'Chronicle', color: 'blue' }, + { id: 'mycelia', label: 'Mycelia', color: 'purple' }, +] + +export default function ConversationsPage() { + const navigate = useNavigate() + const [selectedSources, setSelectedSources] = useState(['chronicle', 'mycelia']) + + const { chronicle, mycelia, anyLoading, allLoaded } = useMultiSourceConversations(selectedSources) + + // Toggle source selection + const toggleSource = (sourceId: ConversationSource) => { + setSelectedSources((prev) => + prev.includes(sourceId) ? prev.filter((s) => s !== sourceId) : [...prev, sourceId] + ) + } + + // Refresh all enabled sources + const handleRefresh = () => { + if (selectedSources.includes('chronicle')) chronicle.refetch() + if (selectedSources.includes('mycelia')) mycelia.refetch() + } + + return ( +
+ {/* Header */} +
+
+
+ +

Conversations

+
+

+ View conversations from multiple sources +

+
+ + +
+ + {/* Source selector */} +
+ +
+ {SOURCES.map((source) => { + const isSelected = selectedSources.includes(source.id) + const baseColor = source.color === 'blue' ? 'blue' : 'purple' + + return ( + + ) + })} +
+ + {selectedSources.length === 0 && ( +

+ + Select at least one source to view conversations +

+ )} +
+ + {/* Conversations columns */} + {selectedSources.length > 0 && ( +
+ {/* Chronicle column */} + {selectedSources.includes('chronicle') && ( +
+
+

+ Chronicle + {chronicle.isLoading && ( +
+ )} +

+ + {chronicle.data.length} conversations + +
+ + {chronicle.error && ( +
+

+ Failed to load Chronicle conversations. Service may be unavailable. +

+
+ )} + + {!chronicle.isLoading && !chronicle.error && chronicle.data.length === 0 && ( +
+ +

No conversations found

+
+ )} + +
+ {chronicle.data.map((conv) => ( + navigate(`/conversations/${conv.conversation_id || conv.audio_uuid}?source=chronicle`)} + /> + ))} +
+
+ )} + + {/* Mycelia column */} + {selectedSources.includes('mycelia') && ( +
+
+

+ Mycelia + {mycelia.isLoading && ( +
+ )} +

+ + {mycelia.data.length} conversations + +
+ + {mycelia.error && ( +
+

+ Failed to load Mycelia conversations. Service may be unavailable. +

+
+ )} + + {!mycelia.isLoading && !mycelia.error && mycelia.data.length === 0 && ( +
+ +

No conversations found

+
+ )} + +
+ {mycelia.data.map((conv) => ( + navigate(`/conversations/${conv.conversation_id || conv.audio_uuid}?source=mycelia`)} + /> + ))} +
+
+ )} +
+ )} +
+ ) +} diff --git a/ushadow/frontend/src/pages/Dashboard.tsx b/ushadow/frontend/src/pages/Dashboard.tsx index dae09734..89c7e9c5 100644 --- a/ushadow/frontend/src/pages/Dashboard.tsx +++ b/ushadow/frontend/src/pages/Dashboard.tsx @@ -1,51 +1,51 @@ -import { Activity, MessageSquare, Plug, Bot, Workflow, TrendingUp, Sparkles } from 'lucide-react' +import { Activity, MessageSquare, Clock, TrendingUp, Sparkles, Brain } from 'lucide-react' +import { useNavigate } from 'react-router-dom' import { useTheme } from '../contexts/ThemeContext' -import { useFeatureFlags } from '../contexts/FeatureFlagsContext' +import { useDashboardData } from '../hooks/useDashboardData' +import { ActivityType } from '../services/api' export default function Dashboard() { const { isDark } = useTheme() - const { isEnabled } = useFeatureFlags() - - // Define all stats with optional feature flag requirements - const allStats = [ - { - label: 'Conversations', - value: '0', - icon: MessageSquare, - accentColor: '#4ade80', // primary-400 - glowColor: 'rgba(74, 222, 128, 0.15)' - }, - { - label: 'MCP Servers', - value: '0', - icon: Plug, - accentColor: '#22c55e', // primary-500 - glowColor: 'rgba(34, 197, 94, 0.15)', - featureFlag: 'mcp_hub' - }, - { - label: 'Active Agents', - value: '0', - icon: Bot, - accentColor: '#c084fc', // accent-400 - glowColor: 'rgba(192, 132, 252, 0.15)', - featureFlag: 'agent_zero' - }, - { - label: 'n8n Workflows', - value: '0', - icon: Workflow, - accentColor: '#a855f7', // accent-500 - glowColor: 'rgba(168, 85, 247, 0.15)', - featureFlag: 'n8n_workflows' - }, - ] - - // Filter stats based on feature flags - const stats = allStats.filter(stat => { - if (!stat.featureFlag) return true - return isEnabled(stat.featureFlag) - }) + const navigate = useNavigate() + const { data, isLoading, error } = useDashboardData(10, 10) + + // Format timestamp as "2m ago", "Yesterday", etc. + const formatTimestamp = (timestamp: string) => { + const date = new Date(timestamp) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + + if (diffMins < 1) return 'Just now' + if (diffMins < 60) return `${diffMins}m ago` + + const diffHours = Math.floor(diffMins / 60) + if (diffHours < 24) return `${diffHours}h ago` + + const diffDays = Math.floor(diffHours / 24) + if (diffDays === 1) return 'Yesterday' + if (diffDays < 7) return `${diffDays}d ago` + + return date.toLocaleDateString() + } + + // Get icon and color for activity type + const getActivityStyle = (type: ActivityType) => { + switch (type) { + case ActivityType.CONVERSATION: + return { icon: MessageSquare, color: '#4ade80' } + case ActivityType.MEMORY: + return { icon: Brain, color: '#22c55e' } + default: + return { icon: Activity, color: '#71717a' } + } + } + + // Combine and sort all activities by timestamp + const allActivities = [ + ...(data?.recent_conversations || []), + ...(data?.recent_memories || []), + ].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) return (
@@ -66,42 +66,76 @@ export default function Dashboard() {
{/* Stats Grid */} -
- {stats.map((stat) => ( -
-
-
-

- {stat.label} -

-

- {stat.value} -

-
- +
+ {/* Conversations Stat */} +
navigate('/conversations')} + style={{ + backgroundColor: isDark ? 'var(--surface-800)' : '#ffffff', + border: `1px solid ${isDark ? 'var(--surface-500)' : '#e4e4e7'}`, + boxShadow: isDark + ? '0 4px 20px rgba(74, 222, 128, 0.15), 0 4px 6px rgba(0, 0, 0, 0.4)' + : '0 4px 6px rgba(0, 0, 0, 0.1)', + }} + > +
+
+

+ Conversations +

+

+ {isLoading ? '...' : data?.stats.conversation_count || '0'} +

+
- ))} +
+ + {/* Memories Stat */} +
navigate('/memories')} + style={{ + backgroundColor: isDark ? 'var(--surface-800)' : '#ffffff', + border: `1px solid ${isDark ? 'var(--surface-500)' : '#e4e4e7'}`, + boxShadow: isDark + ? '0 4px 20px rgba(34, 197, 94, 0.15), 0 4px 6px rgba(0, 0, 0, 0.4)' + : '0 4px 6px rgba(0, 0, 0, 0.1)', + }} + > +
+
+

+ Memories +

+

+ {isLoading ? '...' : data?.stats.memory_count || '0'} +

+
+ +
+
{/* Activity Feed */} @@ -128,16 +162,100 @@ export default function Dashboard() { Recent Activity
-
- -

- No activity yet. Start by configuring your services in Services -

- Not working yet -
+ + {isLoading ? ( +
+
+

+ Loading activities... +

+
+ ) : error ? ( +
+ +

+ Failed to load activities. Please try again. +

+
+ ) : !allActivities.length ? ( +
+ +

+ No activity yet. Start a conversation or create memories to see activity here. +

+
+ ) : ( +
+ {allActivities.map((activity) => { + const style = getActivityStyle(activity.type) + const Icon = style.icon + + return ( +
+
+ +
+ +
+

+ {activity.title} +

+ {activity.description && ( +

+ {activity.description} +

+ )} +
+ + + {formatTimestamp(activity.timestamp)} + + {activity.source && ( + <> + + + {activity.source} + + + )} +
+
+
+ ) + })} +
+ )}
{/* Quick Actions */} @@ -164,9 +282,10 @@ export default function Dashboard() { Quick Actions
-
+
+ - {isEnabled('mcp_hub') && ( - - )} - {isEnabled('n8n_workflows') && ( - - )}
diff --git a/ushadow/frontend/src/pages/FeedPage.tsx b/ushadow/frontend/src/pages/FeedPage.tsx new file mode 100644 index 00000000..67a55963 --- /dev/null +++ b/ushadow/frontend/src/pages/FeedPage.tsx @@ -0,0 +1,429 @@ +/** + * FeedPage + * + * Multi-platform feed ranked by your OpenMemory knowledge graph interests. + * Features: Social/Videos tabs, interest filter chips, ranked post cards, + * source management, refresh. + */ + +import { useState } from 'react' +import { + Radio, + RefreshCw, + Plus, + ChevronLeft, + ChevronRight, + Loader2, + AlertCircle, + Trash2, + Eye, + EyeOff, + MessageSquare, + Play, + Cloud, + PenLine, + Sparkles, +} from 'lucide-react' +import BlueskyComposeModal from '../components/feed/BlueskyComposeModal' +import PostCard from '../components/feed/PostCard' +import YouTubePostCard from '../components/feed/YouTubePostCard' +import InterestChip from '../components/feed/InterestChip' +import AddSourceModal from '../components/feed/AddSourceModal' +import FeedEmptyState from '../components/feed/FeedEmptyState' +import { + useFeedPosts, + useFeedInterests, + useFeedSources, + useRefreshFeed, + useFeedStats, + useGraphInterests, +} from '../hooks/useFeed' + +type FeedTab = 'social' | 'bluesky' | 'following' | 'videos' + +const TAB_TO_PLATFORM: Record = { + social: 'mastodon', + bluesky: 'bluesky', + following: 'bluesky_timeline', + videos: 'youtube', +} + +export default function FeedPage() { + const [activeTab, setActiveTab] = useState('social') + const [page, setPage] = useState(1) + const [selectedInterest, setSelectedInterest] = useState() + const [showSeen, setShowSeen] = useState(true) + const [showAddSource, setShowAddSource] = useState(false) + const [showCompose, setShowCompose] = useState(false) + const [composeReplyTo, setComposeReplyTo] = useState<{ postId: string; handle: string } | undefined>() + + const platformType = TAB_TO_PLATFORM[activeTab] + + const { posts, total, totalPages, isLoading, isFetching, error, markSeen, toggleBookmark } = + useFeedPosts(page, 20, selectedInterest, showSeen, platformType) + const { interests } = useFeedInterests() + const { sources, addSource, isAdding, removeSource, isRemoving } = useFeedSources() + const { refresh, isRefreshing, lastResult } = useRefreshFeed(platformType) + const { stats } = useFeedStats() + const { forceRefresh: regenGraphInterests, isFetching: isRegenGraph } = useGraphInterests() + + // Filter sources for the active tab + const tabSources = sources.filter((s) => s.platform_type === platformType) + const hasTabSources = tabSources.length > 0 + + // For the Following tab, pick the first bluesky_timeline source for compose + const timelineSource = sources.find((s) => s.platform_type === 'bluesky_timeline') + const canCompose = activeTab === 'following' && !!timelineSource + + const handleOpenCompose = () => { + setComposeReplyTo(undefined) + setShowCompose(true) + } + + const handleOpenReply = (postId: string, handle: string) => { + setComposeReplyTo({ postId, handle }) + setShowCompose(true) + } + + const handleTabChange = (tab: FeedTab) => { + setActiveTab(tab) + setPage(1) + setSelectedInterest(undefined) + } + + const handleRefresh = async () => { + try { + await refresh() + setPage(1) + } catch { + // error is available via useRefreshFeed().error + } + } + + const handleInterestClick = (name: string) => { + setSelectedInterest((prev) => (prev === name ? undefined : name)) + setPage(1) + } + + return ( +
+ {/* Page header */} +
+
+ +
+

Feed

+

+ Content ranked by your knowledge graph interests +

+
+
+ +
+ {/* Stats pills */} + {stats && ( +
+ {stats.total_posts} posts + | + {stats.unseen_posts} unseen + | + {stats.sources_count} sources +
+ )} + + {/* Toggle seen */} + + + {/* Add source */} + + + {/* Compose — only shown on the Following (bluesky_timeline) tab */} + {canCompose && ( + + )} + + {/* Regen graph interests */} + + + {/* Refresh — scoped to active tab's platform */} + +
+
+ + {/* Tab bar */} +
+ + + + +
+ + {/* Refresh result banner */} + {lastResult && ( +
+ Fetched {lastResult.posts_fetched} posts, {lastResult.posts_new} new ·{' '} + {lastResult.interests_count} interests used +
+ )} + + {/* Error */} + {error && ( +
+ + {(error as Error).message || 'Failed to load feed'} +
+ )} + + {/* Interest chips */} + {interests.length > 0 && ( +
+ + {interests.map((interest) => ( + handleInterestClick(interest.name)} + /> + ))} +
+ )} + + {/* Sources list — scoped to active tab */} +
+ Sources: + {tabSources.map((s) => ( + + {s.platform_type === 'youtube' && } + {s.platform_type === 'bluesky' && } + {s.name} + + + ))} + +
+ + {/* Loading state */} + {isLoading && ( +
+ +
+ )} + + {/* Empty state */} + {!isLoading && posts.length === 0 && ( + setShowAddSource(true)} + onRefresh={handleRefresh} + isRefreshing={isRefreshing} + /> + )} + + {/* Post list — conditional card type */} + {!isLoading && posts.length > 0 && ( +
+ {posts.map((post) => + post.platform_type === 'youtube' ? ( + + ) : ( + + ), + )} +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} · {total} posts + + +
+ )} + + {/* Fetching indicator (background refetch) */} + {isFetching && !isLoading && ( +
+ + Updating... +
+ )} + + {/* Add source modal */} + setShowAddSource(false)} + onAdd={addSource} + isAdding={isAdding} + defaultPlatform={platformType as 'mastodon' | 'bluesky' | 'bluesky_timeline' | 'youtube'} + /> + + {/* Bluesky compose / reply modal */} + {timelineSource && ( + setShowCompose(false)} + sourceId={timelineSource.source_id} + replyTo={composeReplyTo} + /> + )} +
+ ) +} diff --git a/ushadow/frontend/src/pages/KubernetesClustersPage.tsx b/ushadow/frontend/src/pages/KubernetesClustersPage.tsx index 9d4e70a6..81ae5650 100644 --- a/ushadow/frontend/src/pages/KubernetesClustersPage.tsx +++ b/ushadow/frontend/src/pages/KubernetesClustersPage.tsx @@ -1,10 +1,14 @@ import { useState, useEffect } from 'react' import { createPortal } from 'react-dom' -import { Server, Plus, RefreshCw, Trash2, CheckCircle, XCircle, Clock, Upload, X, Search, Database, AlertCircle, Rocket } from 'lucide-react' +import { Server, Plus, RefreshCw, Trash2, CheckCircle, XCircle, Clock, Upload, X, Search, Database, AlertCircle, Rocket, Globe, Copy, Check, Settings, Shield } from 'lucide-react' +import { useNavigate } from 'react-router-dom' import { kubernetesApi, KubernetesCluster, DeployTarget, deploymentsApi } from '../services/api' import Modal from '../components/Modal' import ConfirmDialog from '../components/ConfirmDialog' import DeployModal from '../components/DeployModal' +import DNSManagementPanel from '../components/kubernetes/DNSManagementPanel' +import InfrastructureOverridesEditor from '../components/InfrastructureOverridesEditor' +import ClusterNodeList from '../components/kubernetes/ClusterNodeList' interface InfraService { found: boolean @@ -20,7 +24,50 @@ interface InfraScanResults { infra_services: Record } +// Helper component for displaying copyable commands +function KubeconfigCommand({ label, command, platform }: { label: string; command: string; platform: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(command) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (err) { + console.error('Failed to copy:', err) + } + } + + return ( +
+
+ {label && ( +
+ {label} + {platform && ({platform})} +
+ )} + + {command} + +
+ +
+ ) +} + export default function KubernetesClustersPage() { + const navigate = useNavigate() const [clusters, setClusters] = useState([]) const [loading, setLoading] = useState(true) const [showAddModal, setShowAddModal] = useState(false) @@ -37,12 +84,24 @@ export default function KubernetesClustersPage() { const [showDeployModal, setShowDeployModal] = useState(false) const [selectedClusterForDeploy, setSelectedClusterForDeploy] = useState(null) + // DNS Management + const [showDnsModal, setShowDnsModal] = useState(false) + const [selectedClusterForDns, setSelectedClusterForDns] = useState(null) + + // Infrastructure overrides + const [showInfraOverrides, setShowInfraOverrides] = useState(null) + // Form state const [clusterName, setClusterName] = useState('') const [kubeconfig, setKubeconfig] = useState('') - const [namespace, setNamespace] = useState('default') + const [namespace, setNamespace] = useState('ushadow') const [error, setError] = useState(null) + // Ingress configuration editing + const [editingCluster, setEditingCluster] = useState(null) + const [ingressDomain, setIngressDomain] = useState('') + const [ingressEnabledByDefault, setIngressEnabledByDefault] = useState(false) + useEffect(() => { loadClusters() }, []) @@ -149,6 +208,29 @@ export default function KubernetesClustersPage() { } } + const handleDeleteInfraScan = async (clusterId: string, namespace: string) => { + if (!confirm(`Delete infrastructure scan for namespace "${namespace}"?`)) { + return + } + + try { + await kubernetesApi.deleteInfraScan(clusterId, namespace) + + // Remove from local state + setScanResults(prev => { + const updated = { ...prev } + delete updated[`${clusterId}-${namespace}`] + return updated + }) + + // Refresh clusters to update infra_scans + await loadClusters() + } catch (err: any) { + console.error('Error deleting infrastructure scan:', err) + alert(`Failed to delete scan: ${err.response?.data?.detail || err.message}`) + } + } + const handleOpenNamespaceSelector = (clusterId: string) => { const cluster = clusters.find(c => c.cluster_id === clusterId) setScanNamespace(cluster?.namespace || 'ushadow') @@ -407,13 +489,23 @@ export default function KubernetesClustersPage() { {foundInfra} in {namespace}
- +
+ + +
) @@ -435,6 +527,114 @@ export default function KubernetesClustersPage() {
)} + {/* Ingress Configuration */} +
+
+

+ Ingress Configuration +

+ {editingCluster !== cluster.cluster_id && ( + + )} +
+ + {editingCluster === cluster.cluster_id ? ( +
+
+ + { + const value = e.target.value.toLowerCase() + if (/^[a-z0-9.-]*$/.test(value)) { + setIngressDomain(value) + } + }} + placeholder="shadow" + className="w-full px-3 py-2 text-sm rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100" + data-testid={`ingress-domain-input-${cluster.cluster_id}`} + /> +
+ + + +
+ + +
+
+ ) : ( +
+ {cluster.ingress_domain ? ( +
+
+ Domain: .{cluster.ingress_domain} +
+
+ Auto-enable: {cluster.ingress_enabled_by_default ? '✓ Yes' : '✗ No'} +
+
+ ) : ( +
+ Not configured +
+ )} +
+ )} +
+ + {/* Nodes */} + + {/* Actions */}
@@ -466,6 +666,40 @@ export default function KubernetesClustersPage() { Deploy + + + + + +
+ {/* Quick Commands Section */} +
+

+ + + + Copy kubeconfig to clipboard +

+ +

+ 💡 Run this command in your terminal, then click "Paste from Clipboard" below +

+
+ {/* Kubeconfig Upload */}
, document.body )} + + {/* Infrastructure Overrides Modal */} + {showInfraOverrides && (() => { + const cluster = clusters.find(c => c.cluster_id === showInfraOverrides) + if (!cluster) return null + return ( + setShowInfraOverrides(null)} + title={`Infrastructure Overrides - ${cluster.name}`} + maxWidth="2xl" + testId="infra-overrides-modal" + > + setShowInfraOverrides(null)} + onCancel={() => setShowInfraOverrides(null)} + /> + + ) + })()} + + {/* DNS Management Modal */} + {showDnsModal && selectedClusterForDns && ( + { + setShowDnsModal(false) + setSelectedClusterForDns(null) + }} + title={`DNS Management - ${selectedClusterForDns.name}`} + maxWidth="2xl" + testId="dns-management-modal" + > + + + )}
) } diff --git a/ushadow/frontend/src/pages/LoginPage.tsx b/ushadow/frontend/src/pages/LoginPage.tsx index 9937ae2a..caf7462b 100644 --- a/ushadow/frontend/src/pages/LoginPage.tsx +++ b/ushadow/frontend/src/pages/LoginPage.tsx @@ -1,33 +1,86 @@ -import React, { useState, useEffect } from 'react' -import { useNavigate, Navigate, useLocation } from 'react-router-dom' -import { useAuth } from '../contexts/AuthContext' -import { Eye, EyeOff } from 'lucide-react' +import React from 'react' +import { useNavigate, useLocation } from 'react-router-dom' +import { useCasdoorAuth } from '../contexts/CasdoorAuthContext' +import { useSettings } from '../contexts/SettingsContext' import AuthHeader from '../components/auth/AuthHeader' +import { LogIn, ExternalLink, UserPlus } from 'lucide-react' +import { setupApi } from '../services/api' export default function LoginPage() { - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [showPassword, setShowPassword] = useState(false) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState('') const navigate = useNavigate() const location = useLocation() + const { isAuthenticated, isLoading, login, register } = useCasdoorAuth() + const { isLoading: settingsLoading } = useSettings() + const [hasUsers, setHasUsers] = React.useState(null) - const { user, login, setupRequired, isLoading: authLoading } = useAuth() + // Parse query parameters once + const searchParams = new URLSearchParams(location.search) + const isLauncherMode = searchParams.get('launcher') === 'true' + const returnTo = searchParams.get('returnTo') - // Get the intended destination from router state (set by ProtectedRoute) - const from = (location.state as { from?: string })?.from || '/' + // Get the intended destination from router state (set by ProtectedRoute) or from query param + // Default to /cluster instead of / to avoid redirect loop + const from = (location.state as { from?: string })?.from || returnTo || '/cluster' + + // Check if any users exist — if not, disable Login to force registration + React.useEffect(() => { + setupApi.getSetupStatus() + .then(res => setHasUsers(res.data.user_count > 0)) + .catch(() => setHasUsers(true)) // Default to allowing login if check fails + }, []) // After successful login, redirect to intended destination - useEffect(() => { - if (user) { - console.log('Login successful, redirecting to:', from) + // Note: Don't redirect if we're on the callback page - that's handled by OAuthCallback component + React.useEffect(() => { + if (isAuthenticated && location.pathname !== '/oauth/callback') { + console.log('[LoginPage] Already authenticated, redirecting to:', from) navigate(from, { replace: true, state: { fromAuth: true } }) } - }, [user, navigate, from]) + }, [isAuthenticated, navigate, from, location.pathname]) + + const handleLogin = async () => { + console.log('[LoginPage] Login button clicked') - // Show loading while checking setup status - if (setupRequired === null || authLoading) { + // If in launcher mode, open in external browser + if (isLauncherMode) { + console.log('[LoginPage] Launcher mode detected, opening in browser') + const url = new URL(window.location.href) + url.searchParams.delete('launcher') + window.open(url.toString(), '_blank') + return + } + + console.log('[LoginPage] Starting SSO login, redirect target:', from) + try { + await login(from) + } catch (error) { + console.error('[LoginPage] Login failed:', error) + } + } + + const handleRegister = async () => { + console.log('[LoginPage] Register button clicked') + + // If in launcher mode, open in external browser + if (isLauncherMode) { + console.log('[LoginPage] Launcher mode detected, opening in browser') + const url = new URL(window.location.href) + url.searchParams.delete('launcher') + url.searchParams.set('register', 'true') + window.open(url.toString(), '_blank') + return + } + + console.log('[LoginPage] Starting SSO registration, redirect target:', from) + try { + await register(from) + } catch (error) { + console.error('[LoginPage] Registration failed:', error) + } + } + + // Show loading while checking authentication + if (isLoading) { return (
- Checking setup status... + Checking authentication...
) } - // Redirect to registration if required - // IMPORTANT: This must be after all hooks to follow Rules of Hooks - if (setupRequired === true) { - return - } - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setIsLoading(true) - setError('') - - const result = await login(email, password) - if (!result.success) { - // Show specific error message based on error type - if (result.errorType === 'connection_failure') { - setError('Unable to connect to server. Please check your connection and try again.') - } else if (result.errorType === 'authentication_failure') { - setError('Invalid email or password') - } else { - setError(result.error || 'Login failed. Please try again.') - } - } - setIsLoading(false) - } - return (
-
- {/* Decorative background blur circles - brand green and purple */} - {/* Using fixed positioning so glows extend to viewport edges, not container edges */} -
-
-
-
+ {/* Geometric grid background pattern */} +
-
- + {/* Diagonal cross pattern overlay */} +
+ +
+
+ - {/* Login Form */} + {/* Login Card */}
-
-
- - setEmail(e.target.value)} - className="appearance-none block w-full px-4 py-3 rounded-lg transition-all sm:text-sm focus:outline-none focus:ring-1" - style={{ - backgroundColor: 'var(--surface-700)', - border: '1px solid var(--surface-400)', - color: 'var(--text-primary)', - }} - placeholder="your@email.com" - data-testid="login-email-input" - /> -
- -
- -
- setPassword(e.target.value)} - className="appearance-none block w-full px-4 py-3 pr-12 rounded-lg transition-all sm:text-sm focus:outline-none focus:ring-1" - style={{ - backgroundColor: 'var(--surface-700)', - border: '1px solid var(--surface-400)', - color: 'var(--text-primary)', - }} - placeholder="Enter your password" - data-testid="login-password-input" - /> - + {isLauncherMode && ( +
+
+ +
+

Authentication Required

+

+ Authentication must be completed in your browser. Click below to continue. +

+
+ )} - {error && ( -
-

{error}

-
+
+

+ Welcome to Ushadow +

+

+ Secure authentication powered by Casdoor +

+
+ + {/* Sign in with Casdoor */} +
+ {/* Login disabled when no users exist — register first */} + {hasUsers === false && ( +

+ No users registered yet. Please register to create the first account. +

)} + -
- + + +
+

+ You'll be redirected to Casdoor for authentication +

- +
+
-

- Ushadow Dashboard v0.1.0 + {/* Info Card */} +

+

+ New to Ushadow? Your administrator will provide you with access credentials.

diff --git a/ushadow/frontend/src/pages/MemoriesPage.tsx b/ushadow/frontend/src/pages/MemoriesPage.tsx index b6faa6cc..97270536 100644 --- a/ushadow/frontend/src/pages/MemoriesPage.tsx +++ b/ushadow/frontend/src/pages/MemoriesPage.tsx @@ -21,15 +21,17 @@ import { Share2, Loader2, Database, + Sparkles, } from 'lucide-react' import { MemoryTable } from '../components/memories/MemoryTable' import { GraphVisualization } from '../components/memories/GraphVisualization' +import { MemoryInterestsTab } from '../components/memories/MemoryInterestsTab' import { useMemories } from '../hooks/useMemories' import { useGraphApi } from '../hooks/useGraphApi' import { useMemoriesStore } from '../stores/memoriesStore' import type { MemorySource } from '../services/api' -type Tab = 'list' | 'graph' +type Tab = 'list' | 'graph' | 'interests' export default function MemoriesPage() { const [activeTab, setActiveTab] = useState('list') @@ -209,6 +211,18 @@ export default function MemoriesPage() { )} +
{/* List View Tab */} @@ -427,6 +441,9 @@ export default function MemoriesPage() {
)} + {/* Interests Tab */} + {activeTab === 'interests' && } + {/* Create Memory Dialog */} {showCreateDialog && (
diff --git a/ushadow/frontend/src/pages/MemoryDetailPage.tsx b/ushadow/frontend/src/pages/MemoryDetailPage.tsx new file mode 100644 index 00000000..8896211d --- /dev/null +++ b/ushadow/frontend/src/pages/MemoryDetailPage.tsx @@ -0,0 +1,548 @@ +import { useParams, useNavigate } from 'react-router-dom' +import { ArrowLeft, Brain, Calendar, Tag, MessageSquare, Edit2, Trash2, AlertCircle, Database, Copy, Check, ExternalLink } from 'lucide-react' +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { unifiedMemoriesApi, memoriesApi, type ConversationMemory } from '../services/api' +import { useConversationDetail } from '../hooks/useConversationDetail' +import ConfirmDialog from '../components/ConfirmDialog' + +interface ConversationLink { + conversation_id: string + title: string + created_at: string + source: 'chronicle' | 'mycelia' +} + +interface RelatedMemory { + id: string + memory: string + categories: string[] + created_at: number + state: string +} + +interface AccessLogEntry { + id: string + app_name: string + accessed_at: string +} + +export default function MemoryDetailPage() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [copiedId, setCopiedId] = useState(false) + + // Fetch memory details using unified backend API + const { data: memory, isLoading, error } = useQuery({ + queryKey: ['memory', id], + queryFn: async () => { + if (!id) throw new Error('Memory ID is required') + const response = await unifiedMemoriesApi.getMemoryById(id) + return response.data + }, + enabled: !!id, + }) + + // Fetch related memories (only for openmemory source) + const { data: relatedMemories, isLoading: relatedLoading } = useQuery({ + queryKey: ['related-memories', id], + queryFn: async () => { + if (!id || !memory) return [] + // Extract user_id from metadata + const userId = memory.metadata?.user_id || memory.metadata?.chronicle_user_email || 'default' + try { + const memories = await memoriesApi.getRelatedMemories(userId, id) + return memories + } catch (err) { + console.error('Failed to fetch related memories:', err) + return [] + } + }, + enabled: !!id && !!memory && memory.source === 'openmemory', + }) + + // Fetch access logs (only for openmemory source) + const { data: accessLogs, isLoading: logsLoading } = useQuery({ + queryKey: ['memory-access-logs', id], + queryFn: async () => { + if (!id) return [] + try { + const result = await memoriesApi.getAccessLogs(id, 1, 10) + return result.logs + } catch (err) { + console.error('Failed to fetch access logs:', err) + return [] + } + }, + enabled: !!id && memory?.source === 'openmemory', + }) + + // Derive conversation link from metadata + const conversationId = memory?.metadata?.source_id + const conversationSource = memory?.metadata ? ( + memory.metadata.conversation_source || + (memory.metadata.app_name?.toLowerCase().includes('mycelia') ? 'mycelia' : 'chronicle') + ) as 'chronicle' | 'mycelia' : 'chronicle' + + // Fetch full conversation details to get title and summary + const { conversation, isLoading: conversationLoading } = useConversationDetail( + conversationId || '', + conversationSource, + { enabled: !!conversationId } + ) + + const handleDelete = async () => { + if (!id || !memory) return + + try { + // For now, show message that delete needs implementation + alert('Memory deletion is not yet implemented for unified memories API') + setShowDeleteDialog(false) + } catch (err) { + console.error('Failed to delete memory:', err) + alert('Failed to delete memory') + } + } + + const handleCopyId = async () => { + if (id) { + await navigator.clipboard.writeText(id) + setCopiedId(true) + setTimeout(() => setCopiedId(false), 2000) + } + } + + const formatDate = (dateString: string) => { + const timestamp = dateString.includes('T') || dateString.includes('-') + ? new Date(dateString).getTime() + : parseInt(dateString) * 1000 + return new Date(timestamp).toLocaleString() + } + + const formatAccessDate = (dateString: string) => { + return new Date(dateString + 'Z').toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + }) + } + + // Extract categories from metadata + // Debug: log memory object to see structure + if (memory) { + console.log('[MemoryDetailPage] Memory object:', memory) + console.log('[MemoryDetailPage] Metadata:', memory.metadata) + console.log('[MemoryDetailPage] Categories from metadata:', memory.metadata?.categories) + } + const categories = memory?.metadata?.categories || [] + console.log('[MemoryDetailPage] Final categories:', categories) + + // Source badge colors + const sourceColors = { + openmemory: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300', + chronicle: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300', + mycelia: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300', + } + + // Category colors + const categoryColors: Record = { + personal: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300', + work: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300', + health: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300', + finance: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300', + travel: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300', + education: 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300', + preferences: 'bg-pink-100 dark:bg-pink-900/30 text-pink-700 dark:text-pink-300', + relationships: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300', + } + + if (isLoading) { + return ( +
+
+
+

Loading memory...

+
+
+ ) + } + + if (error || !memory) { + return ( +
+ + +
+
+ +
+

+ Failed to load memory +

+

+ {error ? String(error) : 'Memory not found'} +

+
+
+
+
+ ) + } + + return ( +
+ {/* Back button */} + + + {/* Main layout: 2/3 content + 1/3 sidebar */} +
+ {/* Main content (2/3) */} +
+ {/* Memory card */} +
+ {/* Header */} +
+
+ +

+ Memory + + #{id?.slice(0, 6)} + +

+ +
+
+ + +
+
+ + {/* Content */} +
+ {/* Memory text with accent border */} +
+

+ {memory.content} +

+
+ + {/* Categories and metadata row */} +
+ {/* Categories */} +
+ {categories.length > 0 && ( + <> + + {categories.map((category: string, idx: number) => ( + + {category} + + ))} + + )} +
+ + {/* Created by */} + {memory.metadata?.app_name && ( +
+ Created by: + + + {memory.metadata.app_name} + +
+ )} +
+ + {/* Metadata section */} + {memory.metadata && Object.keys(memory.metadata).length > 0 && ( +
+

+ Metadata +

+
+
+                      {JSON.stringify(memory.metadata, null, 2)}
+                    
+
+
+ )} + + {/* Additional info */} +
+
+ +
+

Created

+

+ {formatDate(memory.created_at)} +

+
+
+ +
+ +
+

Source

+ + {memory.source} + +
+
+ + {memory.score !== null && memory.score !== undefined && ( +
+ +
+

Relevance

+

+ {(memory.score * 100).toFixed(1)}% +

+
+
+ )} +
+
+
+ + {/* Linked Conversations */} + {conversationId && ( +
+
+ +

+ Linked Conversation +

+
+ + {conversationLoading ? ( +
+

Loading conversation...

+
+ ) : conversation ? ( +
navigate(`/conversations/${conversationId}?source=${conversationSource}`)} + data-testid="conversation-link-0" + > +
+
+ {/* Tags above title */} +
+ {categories.length > 0 && ( + <> + {categories.slice(0, 3).map((category: string, idx: number) => ( + + {category} + + ))} + {categories.length > 3 && ( + + +{categories.length - 3} + + )} + + )} + + {conversationSource === 'chronicle' ? 'Chronicle' : 'Mycelia'} + +
+ + {/* Title */} +

+ {conversation.title || 'Untitled Conversation'} + +

+ + {/* Summary */} + {conversation.summary && ( +

+ {conversation.summary} +

+ )} + + {/* Date */} +

+ {formatDate(conversation.created_at || memory.created_at)} +

+
+
+
+ ) : ( +
+

+ Conversation details not available +

+
+ )} +
+ )} +
+ + {/* Sidebar (1/3) */} +
+ {/* Access Log */} +
+
+

Access Log

+
+
+ {logsLoading ? ( +

+ Loading access logs... +

+ ) : accessLogs && accessLogs.length > 0 ? ( +
+ {accessLogs.map((entry: AccessLogEntry, index: number) => ( +
+
+ +
+ {index < accessLogs.length - 1 && ( +
+ )} +
+ + {entry.app_name} + + + {formatAccessDate(entry.accessed_at)} + +
+
+ ))} +
+ ) : ( +

+ No access logs available +

+ )} +
+
+ + {/* Related Memories */} +
+
+

Related Memories

+
+
+ {relatedLoading ? ( +

+ Loading related memories... +

+ ) : relatedMemories && relatedMemories.length > 0 ? ( +
+ {relatedMemories.map((relMem: RelatedMemory) => ( +
navigate(`/memories/${relMem.id}`)} + data-testid={`related-memory-${relMem.id}`} + > +

+ {relMem.memory} +

+
+ {relMem.categories.slice(0, 2).map((cat, idx) => ( + + {cat} + + ))} + {relMem.state !== 'active' && ( + + {relMem.state} + + )} +
+
+ ))} +
+ ) : ( +

+ No related memories found +

+ )} +
+
+
+
+ + {/* Delete Confirmation Dialog */} + setShowDeleteDialog(false)} + onConfirm={handleDelete} + title="Delete Memory?" + message="Are you sure you want to delete this memory? This action cannot be undone." + confirmLabel="Delete" + variant="danger" + /> +
+ ) +} diff --git a/ushadow/frontend/src/pages/RegistrationPage.tsx b/ushadow/frontend/src/pages/RegistrationPage.tsx index 769022da..d138ccaf 100644 --- a/ushadow/frontend/src/pages/RegistrationPage.tsx +++ b/ushadow/frontend/src/pages/RegistrationPage.tsx @@ -126,9 +126,44 @@ export default function RegistrationPage() { >
-
+
+ {/* First-Time Setup Banner */} +
+
+
+ ℹ️ +
+
+

+ First-Time Setup Required +

+

+ This is your first time launching Ushadow. Please create an admin account below to access the dashboard and configure your AI assistant. +

+
+
+
+ {/* Registration Form */}
{isLoading ? (
- Creating account... + Creating Account...
) : ( - 'Create Admin Account' + '🚀 Create Admin Account & Get Started' )}
diff --git a/ushadow/frontend/src/pages/ServiceConfigsPage.tsx b/ushadow/frontend/src/pages/ServiceConfigsPage.tsx index fd770196..e35d732a 100644 --- a/ushadow/frontend/src/pages/ServiceConfigsPage.tsx +++ b/ushadow/frontend/src/pages/ServiceConfigsPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useMemo } from 'react' -import { useNavigate } from 'react-router-dom' +import { useQueryClient } from '@tanstack/react-query' +import { useNavigate, useLocation } from 'react-router-dom' import { Plus, RefreshCw, @@ -53,6 +54,8 @@ import { DeploymentsTab, type TabType, } from '../components/services' +import { PortConflictDialog } from '../components/services/PortConflictDialog' +import type { PortConflict } from '../hooks/useServiceStart' /** * Extract error message from FastAPI response. @@ -77,6 +80,7 @@ function getErrorMessage(error: any, fallback: string): string { export default function ServiceConfigsPage() { const navigate = useNavigate() + const location = useLocation() const { isEnabled } = useFeatureFlags() // Feature flag: hide service configs (custom instances) @@ -93,6 +97,8 @@ export default function ServiceConfigsPage() { refresh: refreshData, } = useServiceConfigData() + const queryClient = useQueryClient() + // Service catalog hook const catalog = useServiceCatalog() @@ -143,6 +149,9 @@ export default function ServiceConfigsPage() { const [loadingProviderCard, setLoadingProviderCard] = useState(false) const [savingProviderCard, setSavingProviderCard] = useState(false) + // Track toggling deployments (for spinner state) + const [togglingDeployments, setTogglingDeployments] = useState>(new Set()) + // Unified deploy modal state const [deployModalState, setDeployModalState] = useState<{ isOpen: boolean @@ -162,6 +171,21 @@ export default function ServiceConfigsPage() { const [editingDeployment, setEditingDeployment] = useState(null) const [deploymentEnvVars, setDeploymentEnvVars] = useState([]) const [deploymentEnvConfigs, setDeploymentEnvConfigs] = useState>({}) + const [customEnvVars, setCustomEnvVars] = useState>({}) + + // Port conflict state for deployment restarts + const [restartPortConflict, setRestartPortConflict] = useState<{ + isOpen: boolean + deploymentId: string | null + serviceName: string | null + conflicts: PortConflict[] + }>({ + isOpen: false, + deploymentId: null, + serviceName: null, + conflicts: [] + }) + const [resolvingPortConflict, setResolvingPortConflict] = useState(false) // ESC key to close modals const closeAllModals = useCallback(() => { @@ -180,6 +204,14 @@ export default function ServiceConfigsPage() { return () => window.removeEventListener('keydown', handleEsc) }, [closeAllModals]) + // Refresh when navigated here with { state: { refresh: true } } (e.g. from a wizard) + useEffect(() => { + if (location.state?.refresh) { + refreshData() + navigate(location.pathname, { replace: true, state: {} }) + } + }, [location.state]) + // Log data for debugging useEffect(() => { if (templates.length > 0) { @@ -395,9 +427,7 @@ export default function ServiceConfigsPage() { const serviceName = templateId.includes(':') ? templateId.split(':').pop()! : templateId await servicesApi.startService(serviceName) setMessage({ type: 'success', text: `${consumerId} started` }) - // Reload service statuses - const statusesRes = await servicesApi.getAllStatuses() - setServiceStatuses(statusesRes.data || {}) + refreshData() } catch (error: any) { setMessage({ type: 'error', @@ -415,9 +445,7 @@ export default function ServiceConfigsPage() { const serviceName = templateId.includes(':') ? templateId.split(':').pop()! : templateId await servicesApi.stopService(serviceName) setMessage({ type: 'success', text: `${consumerId} stopped` }) - // Reload service statuses - const statusesRes = await servicesApi.getAllStatuses() - setServiceStatuses(statusesRes.data || {}) + refreshData() } catch (error: any) { setMessage({ type: 'error', @@ -426,11 +454,12 @@ export default function ServiceConfigsPage() { } } - const handleDeployConsumer = async (consumerId: string, target: { type: 'local' | 'remote' | 'kubernetes'; id?: string; configId?: string }) => { + const handleDeployConsumer = async (consumerId: string, target: { type: 'local' | 'remote' | 'kubernetes' | 'k8s' | 'docker'; id?: string; configId?: string }) => { // consumerId can be either an instance ID or a template ID (for templates without instances) // Try to find instance first, otherwise treat as template ID const consumerInstance = instances.find(inst => inst.id === consumerId) const templateId = consumerInstance?.template_id || consumerId + console.log('[DEBUG handleDeployConsumer]', { consumerId, consumerInstance: consumerInstance?.id, templateId, configId: target.configId }) // Load ALL available targets (both Docker and K8s) for unified selection setLoadingTargets(true) @@ -444,21 +473,25 @@ export default function ServiceConfigsPage() { // Try to determine a default target based on the button clicked let selectedTarget: DeployTarget | undefined - if (target.type === 'kubernetes') { + if (target.type === 'kubernetes' || target.type === 'k8s') { // K8s button clicked - try to select a K8s cluster const k8sTargets = allTargets.filter(t => t.type === 'k8s') - if (k8sTargets.length === 1) { + // If target has an id, match exactly; otherwise auto-select if only one cluster + if (target.id) { + selectedTarget = k8sTargets.find(t => t.id === target.id || t.identifier === target.id) + } + if (!selectedTarget && k8sTargets.length === 1) { selectedTarget = k8sTargets[0] console.log(`🎯 Auto-selected single K8s cluster: ${selectedTarget.name}`) } - } else if (target.type === 'local') { + } else if (target.type === 'local' || (target.type === 'docker' && !target.id)) { // Local button clicked - select leader Docker unode const dockerTargets = allTargets.filter(t => t.type === 'docker') selectedTarget = dockerTargets.find(t => t.is_leader) || dockerTargets[0] if (selectedTarget) { console.log(`🎯 Auto-selected local Docker target: ${selectedTarget.name}`) } - } else if (target.type === 'remote' && target.id) { + } else if ((target.type === 'remote' || target.type === 'docker') && target.id) { // Remote button clicked - select specific remote unode const dockerTargets = allTargets.filter(t => t.type === 'docker') selectedTarget = dockerTargets.find(t => t.identifier === target.id || t.id === target.id) @@ -497,6 +530,7 @@ export default function ServiceConfigsPage() { const envData = envResponse.data const allEnvVars = [...envData.required_env_vars, ...envData.optional_env_vars] + .sort((a, b) => a.name.localeCompare(b.name)) setEnvVars(allEnvVars) // Load wiring connections for this service to get provider-supplied values @@ -508,7 +542,7 @@ export default function ServiceConfigsPage() { const provider = [...templates, ...instances].find((p) => p.id === conn.source_config_id) if (provider) { // Get the capability mapping (e.g., "mongodb" -> MONGODB_URL, MONGODB_NAME) - const capability = conn.source_capability + const capability = conn.capability // Map capability to env var names based on common patterns if (capability === 'mongodb') { @@ -606,9 +640,13 @@ export default function ServiceConfigsPage() { } } - // Provider and compose templates + // Provider and compose templates for capability slots + // Includes YAML provider templates AND running/installed compose services that declare `provides` const providerTemplates = templates - .filter((t) => t.source === 'provider' && t.provides) + .filter((t) => t.provides && ( + t.source === 'provider' || + (t.source === 'compose' && (t.installed || t.running)) + )) const wiringProviders = [ // Templates (defaults) - show installed providers (configured or needing setup) OR client/upload/remote mode (no config needed) @@ -770,7 +808,7 @@ export default function ServiceConfigsPage() { const response = await svcConfigsApi.getTemplateEnvConfig(providerId) const data = response.data - setProviderCardEnvVars(data) + setProviderCardEnvVars(data.sort((a, b) => a.name.localeCompare(b.name))) // Initialize configs from backend response const initial: Record = {} @@ -779,7 +817,7 @@ export default function ServiceConfigsPage() { name: ev.name, source: (ev.source as 'setting' | 'literal' | 'default') || 'default', setting_path: ev.setting_path, - value: ev.value, + value: ev.value ?? ev.resolved_value ?? null, } } setProviderCardEnvConfigs(initial) @@ -813,6 +851,13 @@ export default function ServiceConfigsPage() { await settingsApi.update(settingsUpdates) } + // Mark provider as installed so it appears in wiring board and instance list + const currentConfig = await settingsApi.getConfig() + const currentInstalled: string[] = currentConfig.data?.installed_services ?? [] + if (!currentInstalled.includes(providerId)) { + await settingsApi.update({ installed_services: [...currentInstalled, providerId] }) + } + // Refresh data refreshData() @@ -856,12 +901,10 @@ export default function ServiceConfigsPage() { } else { // Edit instance - fetch details and load environment configuration try { - let details = instanceDetails[providerId] - if (!details) { - const res = await svcConfigsApi.getServiceConfig(providerId) - details = res.data - setServiceConfigDetails((prev) => ({ ...prev, [providerId]: details })) - } + // Always fetch fresh data when opening the edit modal to avoid stale cache issues + const res = await svcConfigsApi.getServiceConfig(providerId) + const details = res.data + setServiceConfigDetails((prev) => ({ ...prev, [providerId]: details })) const instance = instances.find((i) => i.id === providerId) const template = templates.find((t) => t.id === instance?.template_id) @@ -885,6 +928,7 @@ export default function ServiceConfigsPage() { const envData = envResponse.data const allEnvVars = [...envData.required_env_vars, ...envData.optional_env_vars] + .sort((a, b) => a.name.localeCompare(b.name)) setEnvVars(allEnvVars) // Load wiring connections for this instance to get provider-supplied values @@ -895,7 +939,7 @@ export default function ServiceConfigsPage() { for (const conn of wiringConnections) { const provider = [...templates, ...instances].find((p) => p.id === conn.source_config_id) if (provider) { - const capability = conn.source_capability + const capability = conn.capability // Map capability to env var names if (capability === 'mongodb') { @@ -934,12 +978,14 @@ export default function ServiceConfigsPage() { const instanceValue = initialConfig[envVar.name] if (instanceValue !== undefined) { - // ServiceConfig has an override + // ServiceConfig has an override — value may be a string or a + // { _from_setting: "path" } reference object from the Map dropdown. + const isRef = instanceValue !== null && typeof instanceValue === 'object' && '_from_setting' in instanceValue initialEnvConfigs[envVar.name] = { name: envVar.name, - source: 'new_setting', - value: instanceValue, - setting_path: undefined, + source: isRef ? 'setting' : 'new_setting', + value: isRef ? (envVar.resolved_value || '') : String(instanceValue ?? ''), + setting_path: isRef ? instanceValue._from_setting : undefined, new_setting_path: undefined, } } else { @@ -1071,6 +1117,13 @@ export default function ServiceConfigsPage() { await settingsApi.update(updates) } + // Mark provider as installed so it appears in wiring board and instance list + const currentConfig = await settingsApi.getConfig() + const currentInstalled: string[] = currentConfig.data?.installed_services ?? [] + if (!currentInstalled.includes(editingProvider.id)) { + await settingsApi.update({ installed_services: [...currentInstalled, editingProvider.id] }) + } + setMessage({ type: 'success', text: `${editingProvider.name} settings updated` }) } @@ -1102,9 +1155,8 @@ export default function ServiceConfigsPage() { } await svcConfigsApi.updateServiceConfig(editingProvider.id, { config: configToSave }) - // Refresh instance details - const res = await svcConfigsApi.getServiceConfig(editingProvider.id) - setServiceConfigDetails((prev) => ({ ...prev, [editingProvider.id]: res.data })) + // Refresh instance list so wiring board reflects new config + await queryClient.invalidateQueries({ queryKey: ['service-configs-core'] }) setMessage({ type: 'success', text: `${editingProvider.name} updated` }) } setEditingProvider(null) @@ -1132,15 +1184,23 @@ export default function ServiceConfigsPage() { console.log(`🔍 Filtering deployments: filterCurrentEnvOnly=${filterCurrentEnvOnly}, currentEnv=${currentEnv}, currentComposeProject=${currentComposeProject}`) const filtered = filterCurrentEnvOnly ? deployments.filter((d) => { + // K8s deployments always pass the filter — they don't use env-based naming + if (d.backend_type === 'kubernetes') return true + // Match deployments from the current environment only - // Check if the deployment's hostname matches this environment's compose project or env name - const matches = d.unode_hostname && ( - d.unode_hostname === currentEnv || - d.unode_hostname === currentComposeProject || - d.unode_hostname.startsWith(`${currentComposeProject}.`) + // Check if the deployment's hostname or container name contains the env name + const hostname = d.unode_hostname?.toLowerCase() || '' + const containerName = d.container_name?.toLowerCase() || '' + const matches = ( + hostname === currentEnv || + hostname === currentComposeProject || + hostname.startsWith(`${currentComposeProject}.`) || + hostname.includes(currentEnv) || + containerName.includes(currentComposeProject) || + containerName.includes(currentEnv) ) if (!matches && d.unode_hostname) { - console.log(` ⏭️ Filtered out deployment ${d.id}: hostname=${d.unode_hostname}`) + console.log(` ⏭️ Filtered out deployment ${d.id}: hostname=${d.unode_hostname}, container=${d.container_name}`) } return matches }) @@ -1161,27 +1221,145 @@ export default function ServiceConfigsPage() { // Deployment action handlers const handleStopDeployment = async (deploymentId: string) => { + // Add to toggling set + setTogglingDeployments(prev => new Set(prev).add(deploymentId)) + try { await deploymentActions.stopDeployment(deploymentId) - refreshData() + await refreshData() setMessage({ type: 'success', text: 'Deployment stopped' }) } catch (error: any) { console.error('Failed to stop deployment:', error) setMessage({ type: 'error', text: 'Failed to stop deployment' }) + } finally { + // Remove from toggling set + setTogglingDeployments(prev => { + const next = new Set(prev) + next.delete(deploymentId) + return next + }) } } const handleRestartDeployment = async (deploymentId: string) => { + // Add to toggling set + setTogglingDeployments(prev => new Set(prev).add(deploymentId)) + try { await deploymentActions.restartDeployment(deploymentId) - refreshData() + await refreshData() setMessage({ type: 'success', text: 'Deployment restarted' }) } catch (error: any) { console.error('Failed to restart deployment:', error) - setMessage({ type: 'error', text: 'Failed to restart deployment' }) + + // Check if this is a port conflict error (status 409) + if (error.response?.status === 409 && error.response?.data?.detail) { + const detail = error.response.data.detail + if (typeof detail === 'object' && detail.error === 'port_conflict') { + // Find the deployment to get its name + const deployment = deployments.find(d => d.id === deploymentId) + const template = templates.find(t => t.id === deployment?.service_id) + const serviceName = template?.name || deployment?.service_id || 'Unknown' + + // Extract what's using the port from the error message + let usedBy = 'Another container' + if (detail.message && detail.message.includes('(')) { + const match = detail.message.match(/\((.*?)\)/) + if (match) usedBy = match[1] + } + + // Show port conflict dialog (envVar=null means can't auto-resolve) + setRestartPortConflict({ + isOpen: true, + deploymentId, + serviceName, + conflicts: [{ + port: detail.port, + envVar: null, // null = cannot auto-resolve, requires manual edit + usedBy, + suggestedPort: detail.port + 1 + }] + }) + return + } + } + + setMessage({ type: 'error', text: getErrorMessage(error, 'Failed to restart deployment') }) + } finally { + // Remove from toggling set + setTogglingDeployments(prev => { + const next = new Set(prev) + next.delete(deploymentId) + return next + }) + } + } + + const handleResolveRestartPortConflict = async (envVar: string, newPort: number) => { + const { deploymentId } = restartPortConflict + if (!deploymentId) return + + setResolvingPortConflict(true) + + try { + // For deployments, we need to update the port mapping and redeploy + // This is a simplified version - ideally we'd use the deployment edit flow + const deployment = deployments.find(d => d.id === deploymentId) + if (!deployment) { + throw new Error('Deployment not found') + } + + // Get current deployment config + const currentPorts = deployment.deployed_config?.ports || [] + + // Update the port mapping (replace old port with new port) + const oldPort = restartPortConflict.conflicts[0]?.port + const updatedPorts = currentPorts.map((portStr: string) => { + if (portStr.includes(`:${oldPort}`) || portStr === String(oldPort)) { + // Replace the host port + if (portStr.includes(':')) { + const [_, containerPort] = portStr.split(':') + return `${newPort}:${containerPort}` + } + return String(newPort) + } + return portStr + }) + + // Build updated environment (merge current with any existing configs) + const updatedEnv = deployment.deployed_config?.environment || {} + + // Update the deployment via API + await deploymentsApi.updateDeployment(deploymentId, updatedEnv) + + // Close the dialog + setRestartPortConflict({ + isOpen: false, + deploymentId: null, + serviceName: null, + conflicts: [] + }) + + // Refresh data to show updated deployment + refreshData() + setMessage({ type: 'success', text: `Port updated to ${newPort} and deployment restarted` }) + } catch (error: any) { + console.error('Failed to resolve port conflict:', error) + setMessage({ type: 'error', text: getErrorMessage(error, 'Failed to resolve port conflict') }) + } finally { + setResolvingPortConflict(false) } } + const handleDismissRestartPortConflict = () => { + setRestartPortConflict({ + isOpen: false, + deploymentId: null, + serviceName: null, + conflicts: [] + }) + } + const handleRemoveDeployment = async (deploymentId: string, serviceName: string) => { if (!confirm(`Remove deployment ${serviceName}?`)) return @@ -1205,10 +1383,21 @@ export default function ServiceConfigsPage() { // Load environment variable configuration for this service // Pass deployment target to get properly resolved values const deployTarget = deployment.unode_hostname || deployment.backend_metadata?.cluster_id - const envResponse = await servicesApi.getEnvConfig(template.id, deployTarget) - const envData = envResponse.data - const allEnvVars = [...envData.required_env_vars, ...envData.optional_env_vars] + let allEnvVars: any[] = [] + try { + const envResponse = await servicesApi.getEnvConfig(template.id, deployTarget, deployment.config_id) + const envData = envResponse.data + allEnvVars = [...envData.required_env_vars, ...envData.optional_env_vars] + .sort((a, b) => a.name.localeCompare(b.name)) + } catch (error: any) { + // If service doesn't have env config (404), that's okay - just means no env vars to edit + if (error.response?.status !== 404) { + throw error + } + console.log('Service has no environment variables configured') + } + setDeploymentEnvVars(allEnvVars) // Initialize env configs from deployment's current config @@ -1263,9 +1452,8 @@ export default function ServiceConfigsPage() { try { await svcConfigsApi.createWiring({ source_config_id: sourceConfigId, - source_capability: capability, target_config_id: consumerId, - target_capability: capability, + capability, }) refreshData() // Refresh data from React Query setMessage({ type: 'success', text: `${capability} provider connected` }) @@ -1279,11 +1467,11 @@ export default function ServiceConfigsPage() { const handleWiringClear = async (consumerId: string, capability: string) => { const wire = wiring.find( - (w) => w.target_config_id === consumerId && w.target_capability === capability + (w) => w.target_config_id === consumerId && w.capability === capability ) if (!wire) return try { - await svcConfigsApi.deleteWiring(wire.id) + await svcConfigsApi.deleteWiring(wire.target_config_id, wire.capability) refreshData() // Refresh data from React Query setMessage({ type: 'success', text: `${capability} provider disconnected` }) } catch (error: any) { @@ -1325,6 +1513,22 @@ export default function ServiceConfigsPage() { } const handleStartService = async (serviceId: string) => { + // If the service needs setup and has a wizard, redirect to it instead of deploying + const template = templates.find((t) => t.id === serviceId) + if (template?.needs_setup && template?.wizard) { + // For mycelia, only redirect if token is actually missing + if (template.wizard === 'mycelia') { + try { + const res = await settingsApi.getConfig() + if (res.data.api_keys?.mycelia_token) { + await handleDeployConsumer(serviceId, { type: 'local' }) + return + } + } catch { /* fall through to wizard */ } + } + navigate(`/wizard/${template.wizard}`) + return + } await handleDeployConsumer(serviceId, { type: 'local' }) } @@ -1336,7 +1540,22 @@ export default function ServiceConfigsPage() { handleEditConsumer(serviceId) } - const handleDeployService = (serviceId: string, target: DeployTarget) => { + const handleDeployService = async (serviceId: string, target: DeployTarget) => { + const template = templates.find((t) => t.id === serviceId) + if (template?.needs_setup && template?.wizard) { + // For mycelia, only redirect if token is actually missing + if (template.wizard === 'mycelia') { + try { + const res = await settingsApi.getConfig() + if (res.data.api_keys?.mycelia_token) { + handleDeployConsumer(serviceId, target) + return + } + } catch { /* fall through to wizard */ } + } + navigate(`/wizard/${template.wizard}`) + return + } handleDeployConsumer(serviceId, target) } @@ -1396,6 +1615,8 @@ export default function ServiceConfigsPage() { providerTemplates={providerTemplates} serviceStatuses={serviceStatuses} deployments={filteredDeployments} + togglingDeployments={togglingDeployments} + splitServicesEnabled={isEnabled('split_services')} onAddConfig={showServiceConfigs ? handleAddConfig : () => {}} onWiringChange={handleWiringChange} onWiringClear={handleWiringClear} @@ -1414,7 +1635,8 @@ export default function ServiceConfigsPage() { /> ) : activeTab === 'providers' ? ( addedProviderIds.has(t.id) ? { ...t, installed: true } : t)} expandedProviderId={expandedProviderCard} onToggleExpand={handleExpandProviderCard} envVars={providerCardEnvVars} @@ -1445,6 +1667,7 @@ export default function ServiceConfigsPage() { } @@ -1628,9 +1852,61 @@ export default function ServiceConfigsPage() { })}
- ) : ( -

No environment variables to configure

- )} + ) : null} + + {/* Custom Environment Variables */} +
+
+ + +
+ {Object.keys(customEnvVars).length > 0 && ( +
+ {Object.entries(customEnvVars).map(([name, value]) => ( +
+
+ + setCustomEnvVars(prev => ({ ...prev, [name]: e.target.value }))} + className="input text-sm" + placeholder="Value" + /> +
+ +
+ ))} +
+ )} +

+ Add custom environment variables like MYCELIA_FRONTEND_PORT to override service ports +

+
{/* Action buttons */}
@@ -1639,6 +1915,7 @@ export default function ServiceConfigsPage() { setEditingDeployment(null) setDeploymentEnvVars([]) setDeploymentEnvConfigs({}) + setCustomEnvVars({}) }} className="btn-ghost" > @@ -1658,6 +1935,13 @@ export default function ServiceConfigsPage() { } }) + // Add custom env vars + Object.entries(customEnvVars).forEach(([name, value]) => { + if (value) { + envVars[name] = value + } + }) + // Update deployment with new env vars await deploymentsApi.updateDeployment(editingDeployment.id, envVars) @@ -1665,6 +1949,7 @@ export default function ServiceConfigsPage() { setEditingDeployment(null) setDeploymentEnvVars([]) setDeploymentEnvConfigs({}) + setCustomEnvVars({}) // Refresh deployments list await refreshDeployments() @@ -1861,6 +2146,16 @@ export default function ServiceConfigsPage() { )} + {/* Port Conflict Dialog for Deployment Restarts */} + +
) } diff --git a/ushadow/frontend/src/pages/ServicesPage.tsx b/ushadow/frontend/src/pages/ServicesPage.tsx index d01bd5c1..2420406d 100644 --- a/ushadow/frontend/src/pages/ServicesPage.tsx +++ b/ushadow/frontend/src/pages/ServicesPage.tsx @@ -140,21 +140,6 @@ export default function ServicesPage() { return next }) - // Check if this is mycelia-backend and if token exists - if (serviceName === 'mycelia-backend') { - try { - const settingsResponse = await settingsApi.getAll() - const settings = settingsResponse.data - - // If no mycelia token exists, navigate to wizard - if (!settings.mycelia?.token) { - navigate('/wizard/mycelia') - } - } catch (error) { - console.error('Failed to check mycelia token:', error) - } - } - return } @@ -693,6 +678,9 @@ export default function ServicesPage() {

Services

+ + LEGACY +

Configure providers and compose services @@ -730,6 +718,29 @@ export default function ServicesPage() {

+ {/* Legacy Notice Banner */} +
+
+ +
+

+ Legacy Services Page +

+

+ This is the legacy service management interface. For advanced features like service wiring, + custom configurations, and deployment management, please use the{' '} + + . +

+
+
+
+ {/* Stats */}
@@ -1042,8 +1053,20 @@ export default function ServicesPage() { ) : service.needs_setup ? ( + )} + {!apiKeys.some(k => k.name === 'mycelia_token') && ( + + )} +
+
+ + {tokenError && ( +
+ +

{tokenError}

+
+ )} + + {tokenResult && ( +
+

Credentials saved to settings

+ Client ID: {tokenResult.client_id} + Token: {tokenResult.token_preview} +
+ )} + + {myceliaCredentials && !tokenResult && ( +
+

Stored credentials

+
+

Client ID

+
+ + {myceliaCredentials.client_id} + + +
+
+
+

Token (API Key)

+
+ + {showMyceliaToken ? myceliaCredentials.token : '••••••••••••••••••••'} + + + +
+
+
+ )} +
+

Saved API Keys

- {apiKeys.length > 0 ? ( + {apiKeys.filter(k => !['mycelia_token', 'mycelia_client_id'].includes(k.name)).length > 0 ? (
- {apiKeys.map((key) => ( + {apiKeys.filter(k => !['mycelia_token', 'mycelia_client_id'].includes(k.name)).map((key) => (
-

No API keys saved yet

+

No other API keys saved

Configure services on the Services page to add API keys

)} @@ -420,6 +558,13 @@ export default function SettingsPage() {
)} + {/* Network Tab */} + {activeTab === 'network' && ( +
+ +
+ )} + {/* Debug: Raw Config */} {import.meta.env.DEV && (
diff --git a/ushadow/frontend/src/pages/ShareViewPage.tsx b/ushadow/frontend/src/pages/ShareViewPage.tsx new file mode 100644 index 00000000..9b30814b --- /dev/null +++ b/ushadow/frontend/src/pages/ShareViewPage.tsx @@ -0,0 +1,593 @@ +/** + * Public page for viewing shared resources (conversations, memories, etc.) + * Accessible without authentication unless the share requires it. + */ + +import { useParams } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { useRef, useState } from 'react' +import { api } from '../services/api' +import { AlertCircle, Lock, Clock, Eye, MessageSquare, Calendar, User, Play, Pause } from 'lucide-react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' + +interface SharedResource { + share_token: { + token: string + resource_type: string + resource_id: string + permissions: string[] + expires_at: string | null + max_views: number | null + view_count: number + require_auth: boolean + tailscale_only: boolean + } + resource: { + type: string + id: string + data: any + } + permissions: string[] +} + +export default function ShareViewPage() { + const { token } = useParams<{ token: string }>() + + const { data, isLoading, error } = useQuery({ + queryKey: ['shared-resource', token], + queryFn: async () => { + const response = await api.get(`/api/share/${token}`) + return response.data + }, + enabled: !!token, + retry: false, + }) + + if (isLoading) { + return ( +
+
+
+

Loading shared content...

+
+
+ ) + } + + if (error) { + const errorMessage = (error as any)?.response?.data?.detail || 'Unable to access this shared content' + const statusCode = (error as any)?.response?.status + + return ( +
+
+ {statusCode === 403 ? ( + + ) : ( + + )} +

+ {statusCode === 403 ? 'Access Denied' : 'Share Not Found'} +

+

+ {errorMessage} +

+ {statusCode === 403 && errorMessage.includes('Authentication required') && ( + + Log in to view + + )} +
+
+ ) + } + + if (!data) { + return null + } + + const { share_token, resource } = data + + return ( +
+ {/* Header */} +
+
+
+
+

+ Shared {resource.type} +

+

+ {share_token.require_auth ? 'Private share' : 'Public share'} +

+
+
+ {share_token.expires_at && ( +
+ + Expires {new Date(share_token.expires_at).toLocaleDateString()} +
+ )} + {share_token.max_views && ( +
+ + {share_token.view_count} / {share_token.max_views} views +
+ )} +
+
+
+
+ + {/* Content */} +
+ {resource.type === 'conversation' ? ( + + ) : resource.type === 'memory' ? ( + + ) : ( +
+
+

Resource type: {resource.type}

+
+                {JSON.stringify(resource.data, null, 2)}
+              
+
+
+ )} +
+ + {/* Footer */} +
+ Shared via ushadow +
+
+ ) +} + +/** + * Shared conversation view - mirrors ConversationDetailPage layout + */ +function SharedConversationView({ data }: { data: any }) { + // Audio playback state + const [playingFullAudio, setPlayingFullAudio] = useState(false) + const [playingSegment, setPlayingSegment] = useState(null) + const audioRef = useRef(null) + const segmentTimerRef = useRef(null) + + // Handle error response + if (data?.error) { + return ( +
+
+ +

{data.error}

+
+
+ ) + } + + // Extract conversation fields (support both Chronicle and Mycelia formats) + const title = data?.title || data?.name || 'Untitled Conversation' + const summary = data?.summary || (data?.summaries?.[0]?.text) + const detailedSummary = data?.detailed_summary || data?.details + const segments = data?.segments || [] + const transcript = data?.transcript + + // Time handling for Mycelia format + const startTime = data?.timeRanges?.[0]?.start || data?.created_at || data?.createdAt + const endTime = data?.timeRanges?.[0]?.end || data?.completed_at + + // Calculate duration + const formatDuration = () => { + if (data?.duration_seconds) { + const mins = Math.floor(data.duration_seconds / 60) + const secs = Math.floor(data.duration_seconds % 60) + return `${mins}m ${secs}s` + } + if (data?.timeRanges?.[0]?.start && data?.timeRanges?.[0]?.end) { + const start = new Date(data.timeRanges[0].start).getTime() + const end = new Date(data.timeRanges[0].end).getTime() + const durationSec = Math.floor((end - start) / 1000) + const mins = Math.floor(durationSec / 60) + const secs = durationSec % 60 + return `${mins}m ${secs}s` + } + return null + } + + // Check if we have time range data for audio playback + const hasAudioData = data?.timeRanges?.[0]?.start && data?.timeRanges?.[0]?.end + + // Handle full audio play/pause + const handleFullAudioPlayPause = async () => { + if (playingFullAudio) { + if (audioRef.current) { + audioRef.current.pause() + } + setPlayingFullAudio(false) + return + } + + // Stop any segment that's playing + if (playingSegment) { + if (audioRef.current) { + audioRef.current.pause() + } + if (segmentTimerRef.current) { + window.clearTimeout(segmentTimerRef.current) + segmentTimerRef.current = null + } + setPlayingSegment(null) + } + + try { + if (!audioRef.current) { + audioRef.current = new Audio() + audioRef.current.addEventListener('ended', () => setPlayingFullAudio(false)) + } + + // Mycelia audio endpoint + const conversationStart = data?.timeRanges?.[0]?.start + const conversationEnd = data?.timeRanges?.[0]?.end + + if (!conversationStart || !conversationEnd) { + console.error('[SharedConversation] No conversation time range found') + return + } + + const startUnix = Math.floor(new Date(conversationStart).getTime() / 1000) + const endUnix = Math.ceil(new Date(conversationEnd).getTime() / 1000) + + const myceliaBackendUrl = '/api/services/mycelia-backend/proxy' + const audioUrl = `${myceliaBackendUrl}/api/audio/stream?start=${startUnix}&end=${endUnix}` + + // Fetch with auth headers via axios + const response = await api.get(audioUrl, { responseType: 'blob' }) + const audioBlob = response.data + const objectUrl = URL.createObjectURL(audioBlob) + + // Clean up old object URL + if (audioRef.current.src.startsWith('blob:')) { + URL.revokeObjectURL(audioRef.current.src) + } + + audioRef.current.src = objectUrl + audioRef.current.currentTime = 0 + await audioRef.current.play() + setPlayingFullAudio(true) + } catch (err) { + console.error('[SharedConversation] Error playing full audio:', err) + setPlayingFullAudio(false) + } + } + + // Handle segment play/pause + const handleSegmentPlayPause = async (segmentIndex: number, segment: any) => { + const segmentId = `segment-${segmentIndex}` + + if (playingSegment === segmentId) { + if (audioRef.current) { + audioRef.current.pause() + } + if (segmentTimerRef.current) { + window.clearTimeout(segmentTimerRef.current) + segmentTimerRef.current = null + } + setPlayingSegment(null) + return + } + + // Stop any currently playing audio + if (audioRef.current) { + audioRef.current.pause() + } + if (segmentTimerRef.current) { + window.clearTimeout(segmentTimerRef.current) + segmentTimerRef.current = null + } + setPlayingFullAudio(false) + + try { + if (!audioRef.current) { + audioRef.current = new Audio() + audioRef.current.addEventListener('ended', () => setPlayingSegment(null)) + } + + // Get conversation start time from timeRanges + const conversationStart = data?.timeRanges?.[0]?.start + if (!conversationStart) { + console.error('[SharedConversation] No conversation start time found') + return + } + + // Calculate absolute timestamps for the segment + const convStartTime = new Date(conversationStart).getTime() + const segmentStartTime = convStartTime + (segment.start * 1000) + const segmentEndTime = convStartTime + (segment.end * 1000) + + const startUnix = Math.floor(segmentStartTime / 1000) + const endUnix = Math.ceil(segmentEndTime / 1000) + + const myceliaBackendUrl = '/api/services/mycelia-backend/proxy' + const audioUrl = `${myceliaBackendUrl}/api/audio/stream?start=${startUnix}&end=${endUnix}` + + // Fetch with auth headers via axios + const response = await api.get(audioUrl, { responseType: 'blob' }) + const audioBlob = response.data + const objectUrl = URL.createObjectURL(audioBlob) + + // Clean up old object URL + if (audioRef.current.src.startsWith('blob:')) { + URL.revokeObjectURL(audioRef.current.src) + } + + audioRef.current.src = objectUrl + await audioRef.current.play() + setPlayingSegment(segmentId) + } catch (err) { + console.error('[SharedConversation] Error playing audio segment:', err) + setPlayingSegment(null) + } + } + + const duration = formatDuration() + const hasValidSegments = segments && segments.length > 0 + + return ( +
+ {/* Conversation metadata card */} +
+
+ +
+

+ {title} +

+ {summary && ( +
+ + {summary} + +
+ )} + {detailedSummary && detailedSummary !== summary && ( +
+ + {detailedSummary} + +
+ )} +
+
+ + {/* Play Full Audio Button */} + {hasAudioData && ( +
+ +
+ )} + + {/* Metadata grid */} +
+ {startTime && ( +
+ +
+

Started

+

+ {new Date(startTime).toLocaleString()} +

+
+
+ )} + + {endTime && ( +
+ +
+

Ended

+

+ {new Date(endTime).toLocaleString()} +

+
+
+ )} + + {duration && ( +
+ +
+

Duration

+

+ {duration} +

+
+
+ )} + + {hasValidSegments && ( +
+ +
+

Segments

+

+ {segments.length} +

+
+
+ )} +
+
+ + {/* Transcript */} + {(hasValidSegments || transcript) && ( +
+

+ Transcript +

+ + {hasValidSegments ? ( +
+ {segments.map((segment: any, idx: number) => { + const segmentId = `segment-${idx}` + const isPlaying = playingSegment === segmentId + + return ( +
+
+
+ + {segment.speaker?.charAt(0)?.toUpperCase() || '?'} + +
+
+
+
+
+ + {segment.speaker || 'Unknown'} + + + {Math.floor(segment.start)}s - {Math.floor(segment.end)}s + +
+ {hasAudioData && ( + + )} +
+
+ + {segment.text} + +
+
+
+ ) + })} +
+ ) : transcript ? ( +
+ + {transcript} + +
+ ) : null} +
+ )} + + {/* No transcript message */} + {!hasValidSegments && !transcript && ( +
+ +

No transcript available

+
+ )} +
+ ) +} + +/** + * Shared memory view + */ +function SharedMemoryView({ data }: { data: any }) { + // Handle error response + if (data?.error) { + return ( +
+
+ +

{data.error}

+
+
+ ) + } + + const content = data?.text || data?.content || '' + const createdAt = data?.created_at || data?.createdAt + const metadata = data?.metadata_ || data?.metadata || {} + + return ( +
+
+ + {content} + +
+ + {createdAt && ( +
+

+ Created: {new Date(createdAt).toLocaleString()} +

+
+ )} + + {Object.keys(metadata).length > 0 && ( +
+

Metadata:

+
+            {JSON.stringify(metadata, null, 2)}
+          
+
+ )} +
+ ) +} diff --git a/ushadow/frontend/src/services/api.ts b/ushadow/frontend/src/services/api.ts index 41af818b..8bffddf5 100644 --- a/ushadow/frontend/src/services/api.ts +++ b/ushadow/frontend/src/services/api.ts @@ -62,8 +62,20 @@ export const api = axios.create({ // Add request interceptor to include auth token api.interceptors.request.use((config) => { - const token = localStorage.getItem(getStorageKey('token')) - if (token) { + // Check for Casdoor/OIDC token first (in localStorage) + const kcToken = localStorage.getItem('kc_access_token') + + // Check for native login token (in localStorage - persists) + const nativeToken = localStorage.getItem('ushadow_access_token') + + // Fallback to legacy JWT token (in localStorage) + const legacyToken = localStorage.getItem(getStorageKey('token')) + + // Priority: Casdoor > Native > Legacy (all in localStorage now) + const token = kcToken || nativeToken || legacyToken + + // Only set header if we have a valid token (not null, undefined, or the string "null") + if (token && token !== 'null' && token !== 'undefined') { config.headers.Authorization = `Bearer ${token}` } return config @@ -88,6 +100,11 @@ api.interceptors.response.use( // Token expired or invalid on core ushadow endpoints, redirect to login console.warn('🔐 API: 401 Unauthorized on ushadow endpoint - clearing token and redirecting to login') localStorage.removeItem(getStorageKey('token')) + localStorage.removeItem('ushadow_access_token') + localStorage.removeItem('ushadow_user') + localStorage.removeItem('kc_access_token') + localStorage.removeItem('kc_refresh_token') + localStorage.removeItem('kc_id_token') window.location.href = '/login' } } else if (error.code === 'ECONNABORTED') { @@ -126,6 +143,68 @@ export const chronicleApi = { getConversation: (id: string) => api.get(`/api/chronicle/conversations/${id}`), } +// Mycelia integration endpoints +// Mycelia service name constant - ensures consistency +const MYCELIA_SERVICE = 'mycelia-backend' + +export const myceliaApi = { + // Connection info for service discovery + getConnectionInfo: () => api.get(`/api/services/${MYCELIA_SERVICE}/connection-info`), + + getStatus: () => api.get(`/api/services/${MYCELIA_SERVICE}/proxy/health`), + + // Conversations — queried from the objects resource (isConversation: true) + getConversations: async (params?: { limit?: number; skip?: number }) => { + const response = await api.post(`/api/services/${MYCELIA_SERVICE}/proxy/api/resource/objects`, { + action: 'list', + filters: { isConversation: true }, + options: { + sort: { createdAt: -1 }, + limit: params?.limit ?? 25, + skip: params?.skip ?? 0, + }, + }) + const docs: any[] = Array.isArray(response.data) ? response.data : [] + const conversations = docs.map((doc: any) => { + const timeRange = doc.timeRanges?.[0] + // Normalize MongoDB {$date: "..."} extended JSON to plain ISO strings + const startedAt = timeRange?.start?.$date ?? timeRange?.start ?? null + const endedAt = timeRange?.end?.$date ?? timeRange?.end ?? null + const durationSeconds = startedAt && endedAt + ? Math.round((new Date(endedAt).getTime() - new Date(startedAt).getTime()) / 1000) + : undefined + const summaries: any[] = doc.summaries ?? [] + const llmSummary = summaries.filter((s: any) => s.type === 'llm').pop() + return { + conversation_id: doc._id?.$oid ?? doc._id, + audio_uuid: doc.metadata?.extractedWith?.chunkId ?? doc._id?.$oid ?? doc._id, + title: doc.name ?? 'Untitled', + summary: llmSummary?.text ?? doc.description, + created_at: doc.createdAt?.$date ?? doc.createdAt, + started_at: startedAt, + completed_at: endedAt, + client_id: 'mycelia', + duration_seconds: durationSeconds, + has_memory: summaries.length > 0, + icon: doc.icon, + } + }) + return { data: { conversations, count: conversations.length } } + }, + + // Single conversation by ID — uses mycelia's ConversationService (includes transcript segments) + getConversation: (id: string) => + api.get(`/api/services/${MYCELIA_SERVICE}/proxy/data/conversations/${id}`), + + // Audio Timeline Data + getAudioItems: (params: { start: string; end: string; resolution?: string }) => + api.get(`/api/services/${MYCELIA_SERVICE}/proxy/data/audio/items`, { params }), + + // Generic Resource Access (for MCP-style resources) + callResource: (resourceName: string, body: any) => + api.post(`/api/services/${MYCELIA_SERVICE}/proxy/api/resource/${resourceName}`, body), +} + // MCP integration endpoints export const mcpApi = { getStatus: () => api.get('/api/mcp/status'), @@ -160,6 +239,9 @@ export const settingsApi = { /** Get unmasked secret value by path (server-side only) */ getSecret: (keyPath: string) => api.get<{ value: string }>(`/api/settings/secret/${keyPath}`), + /** Get unmasked Mycelia client_id and token for UI display */ + getMyceliaCredentials: () => api.get<{ client_id: string; token: string }>('/api/settings/mycelia-credentials'), + // Service-specific config namespace getAllServiceConfigs: () => api.get('/api/settings/service-configs'), getServiceConfig: (serviceId: string) => api.get(`/api/settings/service-configs/${serviceId}`), @@ -238,6 +320,9 @@ export const servicesApi = { port: number | null env_var: string | null default_port: number | null + proxy_url: string | null + internal_url: string | null + available: boolean }>(`/api/services/${name}/connection-info`), /** Start a service container */ @@ -268,14 +353,20 @@ export const servicesApi = { getConfig: (name: string) => api.get(`/api/services/${name}/config`), /** Get environment variable configuration with suggestions */ - getEnvConfig: (name: string, deployTarget?: string) => api.get<{ - service_id: string - service_name: string - compose_file: string - requires: string[] - required_env_vars: EnvVarInfo[] - optional_env_vars: EnvVarInfo[] - }>(`/api/services/${name}/env${deployTarget ? `?deploy_target=${encodeURIComponent(deployTarget)}` : ''}`), + getEnvConfig: (name: string, deployTarget?: string, configId?: string) => { + const params = new URLSearchParams() + if (deployTarget) params.set('deploy_target', deployTarget) + if (configId) params.set('config_id', configId) + const qs = params.toString() + return api.get<{ + service_id: string + service_name: string + compose_file: string + requires: string[] + required_env_vars: EnvVarInfo[] + optional_env_vars: EnvVarInfo[] + }>(`/api/services/${name}/env${qs ? `?${qs}` : ''}`) + }, /** Save environment variable configuration */ updateEnvConfig: (name: string, envVars: EnvVarConfig[]) => @@ -319,6 +410,12 @@ export const servicesApi = { /** Generate Mycelia authentication token */ generateMyceliaToken: () => api.post<{ token: string; client_id: string }>('/api/services/mycelia/generate-token'), + + /** Provision Mycelia credentials (idempotent — reuses existing if present) */ + provisionMyceliaTokens: (force = false) => + api.post<{ client_id: string; token_preview: string }>( + `/api/services/mycelia/provision-tokens${force ? '?force=true' : ''}` + ), } // Compose service configuration endpoints @@ -352,6 +449,7 @@ export interface EnvVarInfo { suggestions: EnvVarSuggestion[] locked?: boolean provider_name?: string + is_secret?: boolean // When true, backend already masked the value — skip frontend masking } /** Missing key that needs to be configured for a provider */ @@ -382,10 +480,19 @@ export interface ServiceInfo { } /** Quickstart wizard response - aggregated capability requirements */ +export interface ServiceProfile { + name: string + display_name: string + services: string[] + wizard?: string // ID of setup wizard for this profile (e.g., "mycelia") +} + export interface QuickstartConfig { required_capabilities: CapabilityRequirement[] services: ServiceInfo[] // Full service info, not just names all_configured: boolean + profiles: ServiceProfile[] + active_profile: string | null } export interface PortMapping { @@ -423,6 +530,10 @@ export const quickstartApi = { /** Save quickstart config - save key values (settings_path -> value) */ saveConfig: (keyValues: Record) => api.post<{ success: boolean; saved: number; message: string }>('/api/wizard/quickstart', keyValues), + + /** Switch to a named service profile (updates default_services in overrides) */ + selectProfile: (profile: string) => + api.post<{ profile: string; services: string[] }>('/api/wizard/quickstart/profile', { profile }), } // Docker daemon status (minimal - only checks if Docker is available) @@ -502,6 +613,16 @@ export const clusterApi = { registry: string image: string }>('/api/unodes/versions'), + // Create public unode + createPublicUNode: (data: { tailscale_auth_key: string; hostname?: string; labels?: Record }) => + api.post<{ + success: boolean + message: string + hostname: string + join_token?: string + public_url?: string + compose_project?: string + }>('/api/unodes/create-public', data), // Leader info for mobile app / cluster display getLeaderInfo: () => api.get<{ hostname: string @@ -558,6 +679,66 @@ export interface KubernetesCluster { labels: Record infra_scans?: Record deployment_target_id?: string // Unified deployment target ID: {name}.k8s.{environment} + + // Ingress configuration + ingress_domain?: string + ingress_class?: string + ingress_enabled_by_default?: boolean + tailscale_magicdns_enabled?: boolean +} + +// DNS Management types +export interface DNSMapping { + ip: string + fqdn: string + shortnames: string[] + has_tls: boolean + cert_ready: boolean +} + +export interface DNSStatus { + configured: boolean + domain?: string + coredns_ip?: string + ingress_ip?: string + cert_manager_installed: boolean + mappings: DNSMapping[] + total_services: number +} + +export interface CertificateStatus { + name: string + namespace: string + ready: boolean + secret_name: string + issuer_name: string + dns_names: string[] + not_before?: string + not_after?: string + renewal_time?: string +} + +export interface KubernetesNode { + name: string + cluster_id: string + status: string + ready: boolean + kubelet_version?: string + os_image?: string + kernel_version?: string + container_runtime?: string + cpu_capacity?: string + memory_capacity?: string + cpu_allocatable?: string + memory_allocatable?: string + gpu_capacity_nvidia?: number + gpu_capacity_amd?: number + roles: string[] + internal_ip?: string + external_ip?: string + hostname?: string + taints: Array<{ key: string; value: string; effect: string }> + labels: Record } export const kubernetesApi = { @@ -569,6 +750,17 @@ export const kubernetesApi = { api.get(`/api/kubernetes/${clusterId}`), removeCluster: (clusterId: string) => api.delete(`/api/kubernetes/${clusterId}`), + updateCluster: (clusterId: string, updates: { + name?: string + namespace?: string + infra_namespace?: string + labels?: Record + ingress_domain?: string + ingress_class?: string + ingress_enabled_by_default?: boolean + tailscale_magicdns_enabled?: boolean + }) => + api.patch(`/api/kubernetes/${clusterId}`, updates), // Service management getAvailableServices: () => @@ -582,6 +774,10 @@ export const kubernetesApi = { `/api/kubernetes/${clusterId}/scan-infra`, { namespace } ), + deleteInfraScan: (clusterId: string, namespace: string) => + api.delete<{ cluster_id: string; namespace: string; message: string }>( + `/api/kubernetes/${clusterId}/scan-infra/${namespace}` + ), createEnvmap: (clusterId: string, data: { service_name: string; namespace?: string; env_vars: Record }) => api.post<{ success: boolean; configmap: string | null; secret: string | null; namespace: string }>( `/api/kubernetes/${clusterId}/envmap`, @@ -606,6 +802,56 @@ export const kubernetesApi = { api.get<{ pod_name: string; namespace: string; events: Array<{ type: string; reason: string; message: string; count: number; first_timestamp: string | null; last_timestamp: string | null }> }>( `/api/kubernetes/${clusterId}/pods/${podName}/events?namespace=${namespace}` ), + + // DNS Management + getDnsStatus: (clusterId: string, domain?: string) => + api.get(`/api/kubernetes/${clusterId}/dns/status${domain ? `?domain=${domain}` : ''}`), + setupDns: (clusterId: string, data: { domain: string; acme_email?: string; install_cert_manager?: boolean }) => + api.post<{ success: boolean; message: string; domain: string; cert_manager_installed: boolean }>( + `/api/kubernetes/${clusterId}/dns/setup`, + data + ), + addServiceDns: (clusterId: string, domain: string, data: { service_name: string; namespace?: string; shortnames: string[]; use_ingress?: boolean; enable_tls?: boolean; service_port?: number }) => + api.post<{ success: boolean; message: string; service_name: string; fqdn: string; shortnames: string[]; tls_enabled: boolean }>( + `/api/kubernetes/${clusterId}/dns/services?domain=${domain}`, + data + ), + removeServiceDns: (clusterId: string, serviceName: string, domain: string, namespace: string = 'default') => + api.delete<{ success: boolean; message: string }>( + `/api/kubernetes/${clusterId}/dns/services/${serviceName}?domain=${domain}&namespace=${namespace}` + ), + listCertificates: (clusterId: string, namespace?: string) => + api.get<{ certificates: CertificateStatus[]; total: number }>( + `/api/kubernetes/${clusterId}/dns/certificates${namespace ? `?namespace=${namespace}` : ''}` + ), + + // Node operations + listNodes: (clusterId: string) => + api.get(`/api/kubernetes/${clusterId}/nodes`), +} + +/** A single infrastructure env var with source attribution */ +export interface InfraEnvVar { + name: string + value: string + source: 'default' | 'infrastructure' | 'override' + locked: boolean + is_secret: boolean +} + +/** Unified deploy-target infrastructure env var API */ +export const deploymentTargetsApi = { + /** Get composited infrastructure env vars for a deploy target */ + getInfrastructureEnvVars: (targetId: string) => + api.get<{ env_vars: InfraEnvVar[]; target_name: string }>( + `/api/deployments/targets/${encodeURIComponent(targetId)}/infrastructure-env-vars` + ), + /** Save manual overrides for a deploy target */ + saveInfrastructureOverrides: (targetId: string, overrides: Record) => + api.put<{ success: boolean; saved: number }>( + `/api/deployments/targets/${encodeURIComponent(targetId)}/infrastructure-env-vars`, + overrides + ), } // Service Definition and Deployment types @@ -633,7 +879,9 @@ export interface Deployment { id: string service_id: string unode_hostname: string + backend_type?: string // 'docker' | 'kubernetes' status: 'pending' | 'deploying' | 'running' | 'stopped' | 'failed' | 'removing' + config_id?: string // ServiceConfig ID with env var overrides container_id?: string container_name?: string created_at?: string @@ -647,6 +895,8 @@ export interface Deployment { deployed_config?: Record access_url?: string exposed_port?: number + public_url?: string // Public URL via Tailscale Funnel + metadata?: Record // Deployment metadata } export interface DeployTarget { @@ -673,6 +923,38 @@ export interface DeployTarget { raw_metadata: Record // Original UNode or KubernetesCluster data } +export interface DiscoveredWorkload { + name: string + image: string + status: string + backend_type: 'docker' | 'kubernetes' + container_id?: string + compose_project?: string + compose_service?: string + node_hostname?: string + cluster_id?: string + cluster_name?: string + namespace?: string + k8s_deployment_name?: string + ports: string[] + internal_url?: string + already_adopted: boolean +} + +export interface AdoptRequest { + backend_type: 'docker' | 'kubernetes' + container_name: string + image: string + ports: string[] + status: string + node_hostname?: string + container_id?: string + compose_project?: string + cluster_id?: string + namespace?: string + k8s_deployment_name?: string +} + export const deploymentsApi = { // Deployment targets (unified) listTargets: () => api.get('/api/deployments/targets'), @@ -687,9 +969,12 @@ export const deploymentsApi = { deleteService: (serviceId: string) => api.delete(`/api/deployments/services/${serviceId}`), // Deployments - deploy: (serviceId: string, unodeHostname: string, configId?: string) => - api.post('/api/deployments/deploy', { service_id: serviceId, unode_hostname: unodeHostname, config_id: configId }), - listDeployments: (params?: { service_id?: string; unode_hostname?: string }) => + deploy: (serviceId: string, unodeHostname: string, configId?: string, forceRebuild?: boolean) => + api.post('/api/deployments/deploy', + { service_id: serviceId, unode_hostname: unodeHostname, config_id: configId, force_rebuild: forceRebuild }, + { timeout: 600000 } // 10 minutes for builds + ), + listDeployments: (params?: { service_id?: string; unode_hostname?: string; slim?: boolean }) => api.get('/api/deployments', { params }), getDeployment: (deploymentId: string) => api.get(`/api/deployments/${deploymentId}`), stopDeployment: (deploymentId: string) => api.post(`/api/deployments/${deploymentId}/stop`), @@ -703,6 +988,25 @@ export const deploymentsApi = { // Exposed URLs for service discovery getExposedUrls: (params?: { type?: string; name?: string }) => api.get('/api/deployments/exposed-urls', { params }), + + // Funnel route management (public access) + configureFunnelRoute: (deploymentId: string, route: string, saveToConfig?: boolean) => + api.patch(`/api/deployments/${deploymentId}/funnel`, { + route, + save_to_config: saveToConfig ?? false, + }), + removeFunnelRoute: (deploymentId: string, saveToConfig?: boolean) => + api.delete(`/api/deployments/${deploymentId}/funnel`, { + params: { save_to_config: saveToConfig ?? false }, + }), + getFunnelConfiguration: (deploymentId: string) => + api.get(`/api/deployments/${deploymentId}/funnel`), + + // Find & Adopt + findWorkloads: (serviceId: string) => + api.get(`/api/deployments/find/${serviceId}`), + adoptWorkload: (serviceId: string, req: AdoptRequest) => + api.post(`/api/deployments/adopt/${serviceId}`, req), } // Exposed URL types (for service discovery) @@ -716,6 +1020,27 @@ export interface ExposedUrl { status: string // e.g., "running" } +// Funnel route management types +export interface FunnelRouteResponse { + success: boolean + deployment_id: string + route?: string + route_removed?: string + public_url?: string + previous_route?: string + saved_to_config: boolean + note?: string +} + +export interface FunnelConfiguration { + deployment_id: string + service: string + unode: string + funnel_enabled: boolean + route?: string + public_url?: string +} + // Tailscale Setup Wizard types export interface TailscaleConfig { hostname: string @@ -1242,15 +1567,15 @@ export interface ServiceConfigSummary { provides?: string deployment_target?: string access_url?: string + config?: Record } /** Wiring connection between instances */ export interface Wiring { id: string source_config_id: string - source_capability: string target_config_id: string - target_capability: string + capability: string created_at?: string } @@ -1293,16 +1618,15 @@ export interface ServiceConfigUpdateRequest { /** Request to create wiring */ export interface WiringCreateRequest { source_config_id: string - source_capability: string target_config_id: string - target_capability: string + capability: string } export const svcConfigsApi = { // Templates - /** List all templates (compose services + providers) */ - getTemplates: (source?: TemplateSource) => - api.get('/api/svc-configs/templates', { params: source ? { source } : {} }), + /** List templates. Pass installed=true to get only installed compose services + all providers (faster). */ + getTemplates: (params?: { source?: TemplateSource; installed?: boolean }) => + api.get('/api/svc-configs/templates', { params: params || {} }), /** Get a template by ID */ getTemplate: (templateId: string) => @@ -1359,8 +1683,8 @@ export const svcConfigsApi = { api.post('/api/svc-configs/wiring', data), /** Delete a wiring connection */ - deleteWiring: (wiringId: string) => - api.delete(`/api/svc-configs/wiring/${wiringId}`), + deleteWiring: (targetConfigId: string, capability: string) => + api.delete(`/api/svc-configs/wiring/${targetConfigId}/${capability}`), /** Get wiring for a specific instance */ getServiceConfigWiring: (instanceId: string) => @@ -1481,21 +1805,23 @@ export const tailscaleApi = { api.get<{ exists: boolean; running: boolean; status?: string; id?: string }>('/api/tailscale/caddy/status'), startCaddy: () => api.post<{ status: string; message: string }>('/api/tailscale/container/start-caddy'), - // Mobile app connection - minimal data, app fetches details from /api/unodes/leader/info - getMobileConnectionQR: () => + // Mobile app connection - supports both unode (Tailscale) and k8s (ingress) targets + getMobileConnectionQR: (targetId?: string) => api.get<{ qr_code_data: string connection_data: { type: string v: number hostname: string - ip: string + ip: string | null port: number + platform?: string + cluster_id?: string } hostname: string - tailscale_ip: string + tailscale_ip: string | null api_port: number - }>('/api/tailscale/mobile/connect-qr'), + }>('/api/tailscale/mobile/connect-qr', { params: targetId ? { target_id: targetId } : {} }), // Configuration getConfig: () => api.get('/api/tailscale/config'), @@ -1821,6 +2147,39 @@ export const audioApi = { api.get('/api/providers/audio_consumer/available'), } +// ============================================================================= +// Unified Memories API - Cross-source memory retrieval +// ============================================================================= + +export interface ConversationMemory { + id: string + content: string + created_at: string + metadata: Record + source: 'openmemory' | 'mycelia' | 'chronicle' + score?: number +} + +export interface ConversationMemoriesResponse { + conversation_id: string + conversation_source: 'chronicle' | 'mycelia' + memories: ConversationMemory[] + count: number + sources_queried: string[] +} + +export const unifiedMemoriesApi = { + /** Get all memories for a conversation across all sources (OpenMemory + native backend) */ + getConversationMemories: (conversationId: string, source: 'chronicle' | 'mycelia') => + api.get(`/api/memories/by-conversation/${conversationId}`, { + params: { conversation_source: source } + }), + + /** Get a single memory by ID from any memory source */ + getMemoryById: (memoryId: string) => + api.get(`/api/memories/${memoryId}`), +} + export const githubImportApi = { /** Scan a GitHub repository for docker-compose files */ scan: (github_url: string, branch?: string, compose_path?: string) => @@ -1890,3 +2249,42 @@ export const githubImportApi = { compose_path }), } + +// Dashboard API types +export enum ActivityType { + CONVERSATION = 'conversation', + MEMORY = 'memory', +} + +export interface ActivityEvent { + id: string + type: ActivityType + title: string + description?: string + timestamp: string + metadata: Record + source?: string +} + +export interface DashboardStats { + conversation_count: number + memory_count: number +} + +export interface DashboardData { + stats: DashboardStats + recent_conversations: ActivityEvent[] + recent_memories: ActivityEvent[] + last_updated: string +} + +// Dashboard API endpoints +export const dashboardApi = { + getDashboardData: (conversationLimit: number = 10, memoryLimit: number = 10) => + api.get('/api/dashboard', { + params: { + conversation_limit: conversationLimit, + memory_limit: memoryLimit, + }, + }), +} diff --git a/ushadow/frontend/src/services/chronicleApi.ts b/ushadow/frontend/src/services/chronicleApi.ts index a7678b11..8283e71f 100644 --- a/ushadow/frontend/src/services/chronicleApi.ts +++ b/ushadow/frontend/src/services/chronicleApi.ts @@ -400,7 +400,11 @@ export async function getChronicleWebSocketUrl(path: string = '/ws_pcm'): Promis */ export async function getChronicleAudioUrl(conversationId: string, cropped: boolean = true): Promise { const proxyUrl = await getChronicleProxyUrl() - const token = localStorage.getItem(getStorageKey('token')) || '' + + // Get auth token - prefer Casdoor token, fallback to legacy token + const kcToken = localStorage.getItem('kc_access_token') + const legacyToken = localStorage.getItem(getStorageKey('token')) + const token = kcToken || legacyToken || '' if (!token) { console.warn('[ChronicleAPI] No auth token found for audio URL') @@ -413,6 +417,15 @@ export async function getChronicleAudioUrl(conversationId: string, cropped: bool return url } +/** + * Get memories associated with a conversation + */ +export async function getConversationMemories(conversationId: string) { + const proxyUrl = await getChronicleProxyUrl() + const response = await api.get(`${proxyUrl}/api/conversations/${conversationId}/memories`) + return response.data +} + // ============================================================================= // Legacy compatibility exports // ============================================================================= diff --git a/ushadow/frontend/src/services/feedApi.ts b/ushadow/frontend/src/services/feedApi.ts new file mode 100644 index 00000000..664285e5 --- /dev/null +++ b/ushadow/frontend/src/services/feedApi.ts @@ -0,0 +1,199 @@ +/** + * Feed API Client + * + * HTTP functions for the personalized multi-platform feed feature. + * Uses the shared `api` axios instance (includes JWT auth automatically). + */ + +import { api } from './api' + +export interface FeedPost { + post_id: string + user_id: string + source_id: string + external_id: string + platform_type: string // 'mastodon' | 'youtube' | 'bluesky' | 'bluesky_timeline' + author_handle: string + author_display_name: string + author_avatar: string | null + content: string + url: string + published_at: string + hashtags: string[] + language: string | null + // Mastodon engagement (optional — null for non-mastodon) + boosts_count: number | null + favourites_count: number | null + replies_count: number | null + // YouTube-specific (optional — null for non-youtube) + thumbnail_url?: string | null + video_id?: string | null + channel_title?: string | null + view_count?: number | null + like_count?: number | null + duration?: string | null + // Bluesky-specific (optional — null for non-bluesky) + bluesky_cid?: string | null + // Scoring & interaction + relevance_score: number + matched_interests: string[] + seen: boolean + bookmarked: boolean + fetched_at: string +} + +export interface FeedInterest { + name: string + node_id: string + labels: string[] + relationship_count: number + last_active: string | null + hashtags: string[] +} + +export interface FeedSource { + source_id: string + user_id: string + name: string + platform_type: string + instance_url: string | null + api_key: string | null + handle: string | null + enabled: boolean + created_at: string +} + +export interface BlueskyComposeData { + source_id: string + text: string +} + +export interface FeedResponse { + posts: FeedPost[] + total: number + page: number + page_size: number + total_pages: number +} + +export interface RefreshResult { + status: string + interests_count: number + interests_used?: Array<{ name: string; hashtags: string[]; weight: number }> + posts_fetched: number + posts_scored?: number + posts_new: number + message?: string +} + +export interface SourceCreateData { + name: string + platform_type: string + instance_url?: string + api_key?: string + handle?: string +} + +// ─── Graph interests (new mem0 endpoint) ───────────────────────────────────── + +export interface GraphInterestItem { + name: string + entity_type: string + score: number + mentions: number + relationship: string + all_types: string[] +} + +/** Same shape as GraphInterestItem — separate type for API clarity. */ +export type GraphResearchItem = GraphInterestItem + +export interface GraphUpcomingEvent { + name: string + start: string + end?: string | null + memory_id: string + memory_content: string + emoji?: string | null + entities: string[] +} + +export interface GraphUserInterestsResponse { + user_id: string + interests: GraphInterestItem[] + research_topics: GraphResearchItem[] + upcoming_events: GraphUpcomingEvent[] + graph_available: boolean + unknown_rel_types: string[] + generated_at: string +} + +export interface GraphInterestsParams { + include_depth2?: boolean + max_interests?: number + max_research?: number + upcoming_days?: number + include_interests?: boolean + include_research?: boolean + include_events?: boolean +} + +// ─── API object ─────────────────────────────────────────────────────────────── + +export const feedApi = { + // Posts + getPosts: (params: { + page?: number + page_size?: number + interest?: string + show_seen?: boolean + platform_type?: string + }) => api.get('/api/feed/posts', { params }), + + // Refresh (optionally scoped to one platform) + refresh: (platformType?: string) => + api.post('/api/feed/refresh', null, { + params: platformType ? { platform_type: platformType } : undefined, + }), + + // Interests + getInterests: () => + api.get<{ interests: FeedInterest[] }>('/api/feed/interests'), + + // Sources + getSources: () => + api.get<{ sources: FeedSource[] }>('/api/feed/sources'), + + addSource: (data: SourceCreateData) => + api.post('/api/feed/sources', data), + + removeSource: (sourceId: string) => + api.delete(`/api/feed/sources/${sourceId}`), + + // Post actions + markSeen: (postId: string) => + api.post(`/api/feed/posts/${postId}/seen`), + + bookmarkPost: (postId: string) => + api.post(`/api/feed/posts/${postId}/bookmark`), + + // Graph interests (new mem0 /api/v1/graph/interests endpoint) + getGraphInterests: (userId: string, params?: GraphInterestsParams) => + api.get( + '/api/services/mem0/proxy/api/v1/graph/interests', + { params: { user_id: userId, ...params } }, + ), + + // Stats + getStats: () => + api.get<{ total_posts: number; unseen_posts: number; bookmarked_posts: number; sources_count: number }>( + '/api/feed/stats' + ), + + // Bluesky compose + bskyPost: (data: BlueskyComposeData) => + api.post<{ uri: string; cid: string }>('/api/feed/bluesky/post', data), + + bskyReply: (postId: string, data: BlueskyComposeData) => + api.post<{ uri: string; cid: string }>(`/api/feed/bluesky/reply/${postId}`, data), +} diff --git a/ushadow/frontend/src/types/user.ts b/ushadow/frontend/src/types/user.ts index f3b1122e..a4b1e686 100644 --- a/ushadow/frontend/src/types/user.ts +++ b/ushadow/frontend/src/types/user.ts @@ -1,6 +1,6 @@ export interface User { id: string - name: string + display_name: string email: string is_superuser: boolean api_key?: string diff --git a/ushadow/frontend/src/wizards/LocalServicesWizard.tsx b/ushadow/frontend/src/wizards/LocalServicesWizard.tsx index 77d2005c..647511cf 100644 --- a/ushadow/frontend/src/wizards/LocalServicesWizard.tsx +++ b/ushadow/frontend/src/wizards/LocalServicesWizard.tsx @@ -15,7 +15,7 @@ import { ExternalLink, } from 'lucide-react' -import { settingsApi, servicesApi } from '../services/api' +import { api, settingsApi, svcConfigsApi, deploymentsApi, DeployTarget, Deployment } from '../services/api' import { useWizard } from '../contexts/WizardContext' import { useWizardSteps } from '../hooks/useWizardSteps' import { WizardShell, WizardMessage } from '../components/wizard' @@ -48,7 +48,7 @@ const schema = z.object({ model: z.string().optional(), }), transcription: z.object({ - mode: z.enum(['parakeet', 'whisper']), + mode: z.enum(['parakeet', 'faster-whisper', 'whisper']), url: z.string().url('Must be a valid URL').optional().or(z.literal('')), }), }) @@ -70,8 +70,9 @@ export default function LocalServicesWizard() { const [message, setMessage] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) + const [deployTarget, setDeployTarget] = useState(null) - // Container states + // Container states — names must match the service name in the compose file const [ollamaStatus, setOllamaStatus] = useState({ name: 'ollama', displayName: 'Ollama', @@ -79,19 +80,27 @@ export default function LocalServicesWizard() { }) const [parakeetStatus, setParakeetStatus] = useState({ - name: 'parakeet', + name: 'parakeet-asr', displayName: 'Parakeet', status: 'unknown', }) + const [fasterWhisperStatus, setFasterWhisperStatus] = useState({ + name: 'faster-whisper', + displayName: 'Faster Whisper', + status: 'unknown', + }) + const [coreContainers, setCoreContainers] = useState([ - { name: 'openmemory', displayName: 'OpenMemory', status: 'unknown' }, + { name: 'mem0', displayName: 'OpenMemory', status: 'unknown' }, { name: 'chronicle-backend', displayName: 'Chronicle', status: 'unknown' }, ]) // Available Ollama models const [availableModels, setAvailableModels] = useState([]) const [loadingModels, setLoadingModels] = useState(false) + const [pullModelName, setPullModelName] = useState('llama3.2') + const [pullingModel, setPullingModel] = useState(false) const methods = useForm({ resolver: zodResolver(schema), @@ -112,63 +121,62 @@ export default function LocalServicesWizard() { // Set mode to 'local' when this wizard starts useEffect(() => { setMode('local') - checkContainerStatuses() + loadDeployTarget() }, []) + const loadDeployTarget = async () => { + try { + const response = await deploymentsApi.listTargets() + const leader = response.data.find((t: DeployTarget) => t.type === 'docker' && t.is_leader) + if (leader) { + setDeployTarget(leader) + checkContainerStatuses(leader) + } + } catch (error) { + console.error('Failed to load deployment targets:', error) + } + } + // Check container statuses when entering start_services step useEffect(() => { - if (wizard.currentStep.id === 'start_services') { - checkCoreContainerStatuses() + if (wizard.currentStep.id === 'start_services' && deployTarget) { + checkCoreContainerStatuses(deployTarget) } - }, [wizard.currentStep.id]) + }, [wizard.currentStep.id, deployTarget]) - const checkContainerStatuses = async () => { + const checkContainerStatuses = async (target: DeployTarget) => { try { - const response = await servicesApi.getInstalled() - const services = response.data - - // Check Ollama - const ollamaService = services.find((s) => s.service_name.includes('ollama')) - if (ollamaService) { - setOllamaStatus((prev) => ({ - ...prev, - status: ollamaService.status === 'running' ? 'running' : 'stopped', - })) - if (ollamaService.status === 'running') { - fetchOllamaModels() - } - } + const response = await deploymentsApi.listDeployments({ unode_hostname: target.identifier }) + const deployments: Deployment[] = response.data - // Check Parakeet - const parakeetService = services.find((s) => s.service_name.includes('parakeet')) - if (parakeetService) { - setParakeetStatus((prev) => ({ - ...prev, - status: parakeetService.status === 'running' ? 'running' : 'stopped', - })) + const findStatus = (name: string) => { + const d = deployments.find((dep) => dep.service_id.includes(name)) + return d?.status === 'running' ? 'running' : d ? 'stopped' : 'stopped' } + + const ollamaRunning = findStatus('ollama') === 'running' + setOllamaStatus((prev) => ({ ...prev, status: findStatus('ollama') as ContainerInfo['status'] })) + setParakeetStatus((prev) => ({ ...prev, status: findStatus('parakeet-asr') as ContainerInfo['status'] })) + setFasterWhisperStatus((prev) => ({ ...prev, status: findStatus('faster-whisper') as ContainerInfo['status'] })) + + if (ollamaRunning) fetchOllamaModels() } catch (error) { console.error('Failed to check container statuses:', error) } } - const checkCoreContainerStatuses = async () => { + const checkCoreContainerStatuses = async (target: DeployTarget) => { try { - const response = await servicesApi.getInstalled() - const services = response.data + const response = await deploymentsApi.listDeployments({ unode_hostname: target.identifier }) + const deployments: Deployment[] = response.data setCoreContainers((prev) => prev.map((container) => { - const serviceInfo = services.find( - (s) => s.service_name === container.name || s.service_name.includes(container.name) - ) - if (serviceInfo) { - return { - ...container, - status: serviceInfo.status === 'running' ? 'running' : 'stopped', - } + const deployment = deployments.find((d) => d.service_id.includes(container.name)) + return { + ...container, + status: deployment?.status === 'running' ? 'running' : 'stopped', } - return { ...container, status: 'stopped' } }) ) } catch (error) { @@ -176,20 +184,31 @@ export default function LocalServicesWizard() { } } + const pullOllamaModel = async (modelName: string) => { + if (!modelName.trim()) return + setPullingModel(true) + setMessage({ type: 'success', text: `Pulling ${modelName}... this may take a few minutes.` }) + try { + await api.post('/api/services/ollama/proxy/api/pull', { name: modelName.trim(), stream: false }) + setMessage({ type: 'success', text: `Model ${modelName} pulled successfully!` }) + await fetchOllamaModels() + } catch (error) { + setMessage({ type: 'error', text: `Failed to pull ${modelName}. Is Ollama running?` }) + } finally { + setPullingModel(false) + } + } + const fetchOllamaModels = async () => { setLoadingModels(true) try { - // Try to fetch models from Ollama API - const url = methods.getValues('llm.url') || 'http://localhost:11434' - const response = await fetch(`${url}/api/tags`) - const data = await response.json() - if (data.models) { - setAvailableModels(data.models.map((m: any) => m.name)) - } + // Proxy through backend to avoid CORS and use Docker network routing + const response = await api.get('/api/services/ollama/proxy/api/tags') + const models: string[] = (response.data?.models ?? []).map((m: any) => m.name) + setAvailableModels(models) } catch (error) { console.error('Failed to fetch Ollama models:', error) - // Set some common models as fallback - setAvailableModels(['llama3.2', 'llama3.1', 'mistral', 'phi3', 'gemma2']) + setAvailableModels([]) } finally { setLoadingModels(false) } @@ -199,107 +218,106 @@ export default function LocalServicesWizard() { containerName: string, setStatus: React.Dispatch> ) => { + if (!deployTarget) { + setMessage({ type: 'error', text: 'No deployment target available' }) + return + } setStatus((prev) => ({ ...prev, status: 'starting' })) try { - await servicesApi.startService(containerName) + await deploymentsApi.deploy(containerName, deployTarget.identifier) - // Poll for status let attempts = 0 - const maxAttempts = 15 + const maxAttempts = 60 const pollStatus = async () => { attempts++ try { - const response = await servicesApi.getDockerDetails(containerName) - const isRunning = response.data.status === 'running' + const response = await deploymentsApi.listDeployments({ unode_hostname: deployTarget.identifier }) + const deployment = response.data.find((d: Deployment) => d.service_id.includes(containerName)) - if (isRunning) { + if (deployment?.status === 'running') { setStatus((prev) => ({ ...prev, status: 'running' })) - - // If Ollama, fetch models - if (containerName === 'ollama') { - setTimeout(fetchOllamaModels, 2000) - } - + if (containerName === 'ollama') setTimeout(fetchOllamaModels, 2000) setMessage({ type: 'success', text: `${containerName} started successfully!` }) return } + if (deployment?.status === 'failed') { + setStatus((prev) => ({ ...prev, status: 'error', error: 'Deployment failed' })) + setMessage({ type: 'error', text: `Failed to start ${containerName}` }) + return + } + if (attempts < maxAttempts) { setTimeout(pollStatus, 2000) } else { - setStatus((prev) => ({ - ...prev, - status: 'error', - error: 'Timeout waiting for container to start', - })) + setStatus((prev) => ({ ...prev, status: 'error', error: 'Timeout waiting for container to start' })) } } catch (err) { - if (attempts < maxAttempts) { - setTimeout(pollStatus, 2000) - } + if (attempts < maxAttempts) setTimeout(pollStatus, 2000) } } setTimeout(pollStatus, 2000) } catch (error) { - setStatus((prev) => ({ - ...prev, - status: 'error', - error: getErrorMessage(error, 'Failed to start'), - })) + setStatus((prev) => ({ ...prev, status: 'error', error: getErrorMessage(error, 'Failed to start') })) setMessage({ type: 'error', text: getErrorMessage(error, `Failed to start ${containerName}`) }) } } const startCoreContainer = async (containerName: string) => { + if (!deployTarget) { + setMessage({ type: 'error', text: 'No deployment target available' }) + return + } setCoreContainers((prev) => prev.map((c) => (c.name === containerName ? { ...c, status: 'starting' } : c)) ) try { - await servicesApi.startService(containerName) + await deploymentsApi.deploy(containerName, deployTarget.identifier) let attempts = 0 - const maxAttempts = 10 + const maxAttempts = 60 const pollStatus = async () => { attempts++ try { - const response = await servicesApi.getDockerDetails(containerName) - const isRunning = response.data.status === 'running' + const response = await deploymentsApi.listDeployments({ unode_hostname: deployTarget.identifier }) + const deployment = response.data.find((d: Deployment) => d.service_id.includes(containerName)) - if (isRunning) { + if (deployment?.status === 'running') { setCoreContainers((prev) => prev.map((c) => (c.name === containerName ? { ...c, status: 'running' } : c)) ) - - if (containerName === 'openmemory') { + if (containerName === 'mem0') { updateServiceStatus('openMemory', { configured: true, running: true }) } else if (containerName === 'chronicle-backend') { updateServiceStatus('chronicle', { configured: true, running: true }) } - setMessage({ type: 'success', text: `${containerName} started successfully!` }) return } + if (deployment?.status === 'failed') { + setCoreContainers((prev) => + prev.map((c) => (c.name === containerName ? { ...c, status: 'error', error: 'Deployment failed' } : c)) + ) + return + } + if (attempts < maxAttempts) { setTimeout(pollStatus, 2000) } else { setCoreContainers((prev) => prev.map((c) => - c.name === containerName - ? { ...c, status: 'error', error: 'Timeout waiting for container' } - : c + c.name === containerName ? { ...c, status: 'error', error: 'Timeout waiting for container' } : c ) ) } } catch (err) { - if (attempts < maxAttempts) { - setTimeout(pollStatus, 2000) - } + if (attempts < maxAttempts) setTimeout(pollStatus, 2000) } } @@ -315,37 +333,91 @@ export default function LocalServicesWizard() { } } - const saveConfiguration = async (): Promise => { + const saveLlmConfig = async (): Promise => { setIsSubmitting(true) - setMessage({ type: 'info', text: 'Saving local service configuration...' }) + try { + const data = methods.getValues() + const url = data.llm.mode === 'container' ? 'http://ollama:11434' : data.llm.url + const model = data.llm.model || 'llama3.2' + + // Save as a ServiceConfig using credential keys so the capability resolver picks them up + try { + await svcConfigsApi.createServiceConfig({ + id: 'ollama', + template_id: 'ollama', + name: 'Ollama', + config: { base_url: url, model }, + }) + } catch { + // Config already exists — update it + await svcConfigsApi.updateServiceConfig('ollama', { config: { base_url: url, model } }) + } + // Mark ollama as installed so it appears in the wiring board and capability selector + const currentConfig = await settingsApi.getConfig() + const currentInstalled: string[] = currentConfig.data?.installed_services ?? [] + if (!currentInstalled.includes('ollama')) { + await settingsApi.update({ installed_services: [...currentInstalled, 'ollama'] }) + } + + setMessage({ type: 'success', text: 'LLM configuration saved.' }) + return true + } catch (error) { + setMessage({ type: 'error', text: getErrorMessage(error, 'Failed to save LLM configuration') }) + return false + } finally { + setIsSubmitting(false) + } + } + + const saveTranscriptionConfig = async (): Promise => { + setIsSubmitting(true) try { const data = methods.getValues() - const updates: Record = { - service_preferences: { - llm: { - provider: 'local', - url: data.llm.mode === 'container' ? 'http://localhost:11434' : data.llm.url, - model: data.llm.model || 'llama3.2', - }, - transcription: { - provider: 'local', - url: - data.transcription.mode === 'parakeet' - ? 'http://localhost:9000' - : data.transcription.url, - type: data.transcription.mode, - }, - }, + // Map wizard mode to provider template ID and server_url credential value + let providerId: string + let serverUrl: string | null = null + + if (data.transcription.mode === 'parakeet') { + providerId = 'parakeet' + // Parakeet uses its default URL from the provider registry; no explicit URL needed + } else if (data.transcription.mode === 'faster-whisper') { + // faster-whisper serves an OpenAI-compatible whisper API — map to whisper-local provider + providerId = 'whisper-local' + serverUrl = 'http://faster-whisper:8000' + } else { + // Manual whisper URL + providerId = 'whisper-local' + serverUrl = data.transcription.url + } + + // Save as a ServiceConfig using credential keys so the capability resolver picks them up + const configValues = serverUrl ? { server_url: serverUrl } : {} + try { + await svcConfigsApi.createServiceConfig({ + id: providerId, + template_id: providerId, + name: providerId === 'parakeet' ? 'Parakeet' : 'Whisper (local)', + config: configValues, + }) + } catch { + // Config already exists — update it + await svcConfigsApi.updateServiceConfig(providerId, { config: configValues }) } - await settingsApi.update(updates) - updateServiceStatus('apiKeys', true) // Mark as configured (using local instead) - setMessage({ type: 'success', text: 'Configuration saved!' }) + // Mark provider as installed so it appears in the wiring board and capability selector + const currentConfig = await settingsApi.getConfig() + const currentInstalled: string[] = currentConfig.data?.installed_services ?? [] + if (!currentInstalled.includes(providerId)) { + await settingsApi.update({ installed_services: [...currentInstalled, providerId] }) + } + + updateServiceStatus('apiKeys', true) + setMessage({ type: 'success', text: 'Transcription configuration saved.' }) return true } catch (error) { - setMessage({ type: 'error', text: getErrorMessage(error, 'Failed to save configuration') }) + setMessage({ type: 'error', text: getErrorMessage(error, 'Failed to save transcription configuration') }) return false } finally { setIsSubmitting(false) @@ -368,25 +440,25 @@ export default function LocalServicesWizard() { setMessage({ type: 'error', text: 'Please enter the Ollama URL' }) return } + const saved = await saveLlmConfig() + if (!saved) return wizard.next() } else if (wizard.currentStep.id === 'transcription') { const data = methods.getValues() if (data.transcription.mode === 'parakeet' && parakeetStatus.status !== 'running') { - setMessage({ - type: 'error', - text: 'Please start Parakeet or switch to Whisper URL configuration', - }) + setMessage({ type: 'error', text: 'Please start Parakeet or switch to another option' }) + return + } + if (data.transcription.mode === 'faster-whisper' && fasterWhisperStatus.status !== 'running') { + setMessage({ type: 'error', text: 'Please start Faster Whisper or switch to another option' }) return } if (data.transcription.mode === 'whisper' && !data.transcription.url) { setMessage({ type: 'error', text: 'Please enter the Whisper API URL' }) return } - - // Save configuration before proceeding - const saved = await saveConfiguration() + const saved = await saveTranscriptionConfig() if (!saved) return - wizard.next() } else if (wizard.currentStep.id === 'start_services') { if (!allCoreContainersRunning) { @@ -430,8 +502,12 @@ export default function LocalServicesWizard() { ollamaStatus={ollamaStatus} availableModels={availableModels} loadingModels={loadingModels} + pullModelName={pullModelName} + pullingModel={pullingModel} onStartOllama={() => startContainer('ollama', setOllamaStatus)} onRefreshModels={fetchOllamaModels} + onPullModel={pullOllamaModel} + onPullModelNameChange={setPullModelName} /> )} @@ -439,7 +515,9 @@ export default function LocalServicesWizard() { {wizard.currentStep.id === 'transcription' && ( startContainer('parakeet', setParakeetStatus)} + onStartFasterWhisper={() => startContainer('faster-whisper', setFasterWhisperStatus)} /> )} @@ -448,7 +526,7 @@ export default function LocalServicesWizard() { deployTarget && checkCoreContainerStatuses(deployTarget)} /> )} @@ -469,16 +547,24 @@ interface LLMStepProps { ollamaStatus: ContainerInfo availableModels: string[] loadingModels: boolean + pullModelName: string + pullingModel: boolean onStartOllama: () => void onRefreshModels: () => void + onPullModel: (name: string) => void + onPullModelNameChange: (name: string) => void } function LLMStep({ ollamaStatus, availableModels, loadingModels, + pullModelName, + pullingModel, onStartOllama, onRefreshModels, + onPullModel, + onPullModelNameChange, }: LLMStepProps) { const { register, watch, setValue } = useFormContext() const mode = watch('llm.mode') @@ -567,21 +653,66 @@ function LLMStep({ Refresh
- -

- Don't see your model? Pull it with: ollama pull model-name -

+ {loadingModels ? ( +
+ + Loading models... +
+ ) : availableModels.length === 0 ? ( +
+

No models installed

+

+ Pull a model to get started: +

+
+ onPullModelNameChange(e.target.value)} + placeholder="llama3.2" + className="input flex-1 text-sm" + disabled={pullingModel} + /> + +
+ {pullingModel && ( +

+ Downloading model — this may take a few minutes depending on size. +

+ )} +
+ ) : ( + + )} + {availableModels.length > 0 && ( +

+ Don't see your model? Pull it with: ollama pull model-name +

+ )}
)}
@@ -630,15 +761,17 @@ function LLMStep({ // Step 2: Transcription Configuration interface TranscriptionStepProps { parakeetStatus: ContainerInfo + fasterWhisperStatus: ContainerInfo onStartParakeet: () => void + onStartFasterWhisper: () => void } -function TranscriptionStep({ parakeetStatus, onStartParakeet }: TranscriptionStepProps) { +function TranscriptionStep({ parakeetStatus, fasterWhisperStatus, onStartParakeet, onStartFasterWhisper }: TranscriptionStepProps) { const { register, watch, setValue } = useFormContext() const mode = watch('transcription.mode') return ( -
+

@@ -652,10 +785,10 @@ function TranscriptionStep({ parakeetStatus, onStartParakeet }: TranscriptionSte

{/* Mode Selection */} -
+
+ +
{/* Option 4: Expo Go (Limited) */} diff --git a/ushadow/frontend/src/wizards/MyceliaWizard.tsx b/ushadow/frontend/src/wizards/MyceliaWizard.tsx deleted file mode 100644 index 90d0c1ab..00000000 --- a/ushadow/frontend/src/wizards/MyceliaWizard.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import { useState, useEffect } from 'react' -import { useNavigate } from 'react-router-dom' -import { Database, CheckCircle, Loader2, AlertCircle } from 'lucide-react' - -import { servicesApi, settingsApi, EnvVarInfo, EnvVarConfig, EnvVarSuggestion } from '../services/api' -import { useWizardSteps } from '../hooks/useWizardSteps' -import { WizardShell, WizardMessage } from '../components/wizard' -import type { WizardStep } from '../types/wizard' -import { getErrorMessage } from './wizard-utils' -import EnvVarEditor from '../components/EnvVarEditor' - -// Steps -const STEPS: WizardStep[] = [ - { id: 'setup', label: 'Setup' }, - { id: 'complete', label: 'Complete' }, -] as const - -export default function MyceliaWizard() { - const navigate = useNavigate() - const wizard = useWizardSteps(STEPS) - const [message, setMessage] = useState(null) - const [isStarting, setIsStarting] = useState(false) - const [serviceStarted, setServiceStarted] = useState(false) - const [tokenData, setTokenData] = useState<{ token: string; clientId: string } | null>(null) - - // EnvVar state for AUTH_SECRET_KEY - const [authSecretKeyEnvVar, setAuthSecretKeyEnvVar] = useState({ - name: 'AUTH_SECRET_KEY', - is_required: true, - source: 'setting', - suggestions: [], - }) - const [authSecretKeyConfig, setAuthSecretKeyConfig] = useState({ - source: 'setting', - setting_path: 'security.auth_secret_key', - }) - - // Load existing auth secret key and suggestions from settings - useEffect(() => { - loadAuthSecretKey() - }, []) - - const loadAuthSecretKey = async () => { - try { - const response = await settingsApi.getAll() - const settings = response.data - - // Build suggestions from existing secret keys in settings - const suggestions: EnvVarSuggestion[] = [] - - // Add main auth secret key if exists - if (settings.security?.auth_secret_key) { - suggestions.push({ - path: 'security.auth_secret_key', - label: 'ushadow Auth Secret Key', - has_value: true, - value: '•'.repeat(20), - }) - } - - // Add other potential secret keys - if (settings.api_keys) { - Object.keys(settings.api_keys).forEach(key => { - if (settings.api_keys[key]) { - suggestions.push({ - path: `api_keys.${key}`, - label: key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), - has_value: true, - value: '•'.repeat(20), - }) - } - }) - } - - // Update envVar with suggestions - setAuthSecretKeyEnvVar(prev => ({ - ...prev, - suggestions, - resolved_value: settings.security?.auth_secret_key ? '•'.repeat(20) : undefined, - })) - - // If auth_secret_key already exists, use it - if (settings.security?.auth_secret_key) { - setAuthSecretKeyConfig({ - source: 'setting', - setting_path: 'security.auth_secret_key', - value: settings.security.auth_secret_key, - }) - } - - // Check if already configured - const hasToken = settings.api_keys?.mycelia_token - const hasClientId = settings.api_keys?.mycelia_client_id - - if (hasToken && hasClientId) { - // Already configured - skip to complete step - setServiceStarted(true) - setTokenData({ token: hasToken, clientId: hasClientId }) - wizard.goTo('complete') - } - } catch (err) { - console.error('Failed to load auth secret key:', err) - setMessage({ - type: 'error', - text: getErrorMessage(err, 'Failed to load configuration'), - }) - } - } - - const handleAuthSecretKeyChange = (updates: Partial) => { - setAuthSecretKeyConfig(prev => ({ ...prev, ...updates })) - } - - const handleStartAndGenerate = async () => { - setIsStarting(true) - setMessage(null) - - try { - // Determine which setting path to use - const settingPath = authSecretKeyConfig.setting_path || authSecretKeyConfig.new_setting_path - - if (!settingPath) { - setMessage({ - type: 'error', - text: 'Please select or enter an AUTH_SECRET_KEY', - }) - setIsStarting(false) - return - } - - // 1. If creating a new setting, save it first - if (authSecretKeyConfig.source === 'new_setting' && authSecretKeyConfig.value) { - await settingsApi.update({ - [settingPath]: authSecretKeyConfig.value, - }) - } - - // 2. Copy to security.auth_secret_key if using a different path - // Use backend API to get unmasked secret value - if (settingPath !== 'security.auth_secret_key') { - const secretResponse = await settingsApi.getSecret(settingPath) - await settingsApi.update({ - 'security.auth_secret_key': secretResponse.data.value, - }) - } - // If already using security.auth_secret_key, no need to copy - - // 2. Start the Mycelia service (will use the AUTH_SECRET_KEY env var) - await servicesApi.startService('mycelia-backend') - setMessage({ type: 'success', text: 'Mycelia service started. Generating credentials...' }) - - // 3. Wait a bit for service to be ready - await new Promise(resolve => setTimeout(resolve, 5000)) - - // 4. Generate token (runs inside the now-running container) - const response = await servicesApi.generateMyceliaToken() - const { token, client_id } = response.data - - // 5. Save token and client_id to secrets.yml - // Use api_keys.* namespace to ensure both are saved to secrets.yml - await settingsApi.update({ - 'api_keys.mycelia_token': token, - 'api_keys.mycelia_client_id': client_id, - }) - - setTokenData({ token, clientId: client_id }) - setServiceStarted(true) - setMessage({ type: 'success', text: 'Credentials generated successfully!' }) - } catch (error) { - console.error('Failed to start and generate:', error) - setMessage({ - type: 'error', - text: getErrorMessage(error, 'Setup failed. Please try again.'), - }) - } finally { - setIsStarting(false) - } - } - - const handleNext = async () => { - if (wizard.currentStep.id === 'setup') { - if (!serviceStarted) { - await handleStartAndGenerate() - if (serviceStarted) { - wizard.next() - } - } else { - wizard.next() - } - } else if (wizard.currentStep.id === 'complete') { - // Navigate to services page - navigate('/services') - } - } - - const handleBack = () => { - if (!wizard.isFirst) { - wizard.back() - } - } - - return ( - - {wizard.currentStep.id === 'setup' && ( - - )} - {wizard.currentStep.id === 'complete' && ( - - )} - - ) -} - -// ============================================================================= -// Step Components -// ============================================================================= - -interface SetupStepProps { - authSecretKeyEnvVar: EnvVarInfo - authSecretKeyConfig: EnvVarConfig - onAuthSecretKeyChange: (updates: Partial) => void - isStarting: boolean - serviceStarted: boolean -} - -function SetupStep({ authSecretKeyEnvVar, authSecretKeyConfig, onAuthSecretKeyChange, isStarting, serviceStarted }: SetupStepProps) { - return ( -
- {isStarting ? ( -
-
- -
-

- Starting Mycelia... -

-

- Starting the service and generating authentication credentials... -

-
-
-
- ) : serviceStarted ? ( -
-
- -
-

- Setup Complete -

-

- Mycelia has been started and credentials have been generated successfully. -

-
-
-
- ) : ( - <> -
-
- -
-

- Configure Authentication -

-

- Mycelia uses an AUTH_SECRET_KEY to sign JWTs and secure your data. Select an existing secret or create a new one. -

-
-
-
- -
-
-

- Authentication Key -

-
- -
- -
-

- What happens next? -

-
    -
  • - 1. - Save AUTH_SECRET_KEY to settings -
  • -
  • - 2. - Start Mycelia service with proper configuration -
  • -
  • - 3. - Generate authentication token in the running container -
  • -
  • - 4. - Save credentials to settings -
  • -
-
- - )} -
- ) -} - -interface CompleteStepProps { - tokenData: { token: string; clientId: string } | null -} - -function CompleteStep({ tokenData }: CompleteStepProps) { - return ( -
-
-
- -
-
-

- Mycelia is Ready! -

-

- Your AI memory and timeline service is configured and ready to start. -

-
-
- -
-

- What's Next? -

-
    -
  • - - Access the web UI at https://localhost:14433 -
  • -
  • - - Connect Apple Voice Memos, Google Drive, or local audio files -
  • -
  • - - Search your voice notes and conversations -
  • -
-
- - {tokenData && ( -
-
- -
-

Credentials saved

-

Your token and client ID have been saved to ushadow settings and will be automatically passed to Mycelia.

-
-
-
- )} -
- ) -} diff --git a/ushadow/frontend/src/wizards/QuickstartWizard.tsx b/ushadow/frontend/src/wizards/QuickstartWizard.tsx index 0e297358..01835f21 100644 --- a/ushadow/frontend/src/wizards/QuickstartWizard.tsx +++ b/ushadow/frontend/src/wizards/QuickstartWizard.tsx @@ -1,8 +1,18 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { useNavigate } from 'react-router-dom' -import { Sparkles, Loader2, RefreshCw } from 'lucide-react' - -import { servicesApi, quickstartApi, type QuickstartConfig, type CapabilityRequirement, type ServiceInfo } from '../services/api' +import { Sparkles, Loader2, RefreshCw, CheckCircle, ExternalLink } from 'lucide-react' + +import { + quickstartApi, + deploymentsApi, + settingsApi, + type QuickstartConfig, + type CapabilityRequirement, + type ServiceInfo, + type ServiceProfile, + type DeployTarget, + type Deployment +} from '../services/api' import { ServiceStatusCard, type ServiceStatus } from '../components/services' import { RequiredFieldsForm } from '../components/forms' import { useWizard } from '../contexts/WizardContext' @@ -50,6 +60,7 @@ export default function QuickstartWizard() { * QuickstartWizard content - uses WizardFormContext for form handling */ function QuickstartWizardContent() { + const navigate = useNavigate() const { markPhaseComplete, updateServiceStatus, updateApiKeysStatus } = useWizard() const { getValue, saveToApi } = useWizardForm() const wizard = useWizardSteps(STEPS) @@ -61,18 +72,54 @@ function QuickstartWizardContent() { // Container states - built dynamically from API response const [containers, setContainers] = useState([]) + const [isProfileChanging, setIsProfileChanging] = useState(false) + const [hasAutoStarted, setHasAutoStarted] = useState(false) + + // Deployment target (local leader node) + const [deployTarget, setDeployTarget] = useState(null) useEffect(() => { loadQuickstartConfig() + loadDeploymentTarget() }, []) - // Check container status when entering start_services step + // Auto-start all stopped services when entering start_services step + const autoStartServices = useCallback(async (currentContainers: ContainerInfo[]) => { + if (!deployTarget) return + try { + const response = await deploymentsApi.listDeployments({ unode_hostname: deployTarget.identifier }) + const deployments = response.data + for (const container of currentContainers) { + const deployment = deployments.find((d: Deployment) => d.service_id.includes(container.name)) + if (!deployment || deployment.status !== 'running') { + startContainer(container.name) + } + } + } catch (err) { + console.error('[QuickstartWizard] Auto-start failed:', err) + } + }, [deployTarget]) + + // Check status + auto-start when entering start_services step useEffect(() => { if (wizard.currentStep.id === 'start_services') { checkContainerStatuses() } }, [wizard.currentStep.id]) + // Trigger auto-start once deployTarget and containers are both ready + useEffect(() => { + if ( + wizard.currentStep.id === 'start_services' && + deployTarget && + containers.length > 0 && + !hasAutoStarted + ) { + setHasAutoStarted(true) + autoStartServices(containers) + } + }, [wizard.currentStep.id, deployTarget, containers.length, hasAutoStarted, autoStartServices]) + const loadQuickstartConfig = async () => { try { const response = await quickstartApi.getConfig() @@ -94,6 +141,35 @@ function QuickstartWizardContent() { } } + const loadDeploymentTarget = async () => { + try { + const response = await deploymentsApi.listTargets() + // Find the local leader (Docker target with is_leader=true) + const leader = response.data.find((t: DeployTarget) => t.type === 'docker' && t.is_leader) + if (leader) { + setDeployTarget(leader) + console.log('[QuickstartWizard] Found local leader:', leader.identifier) + } else { + console.warn('[QuickstartWizard] No local leader found in deployment targets') + } + } catch (error) { + console.error('Failed to load deployment targets:', error) + } + } + + const handleProfileSelect = async (profileName: string) => { + if (isProfileChanging) return + setIsProfileChanging(true) + try { + await quickstartApi.selectProfile(profileName) + await loadQuickstartConfig() + } catch (error) { + setMessage({ type: 'error', text: `Failed to apply profile: ${getErrorMessage(error, 'Unknown error')}` }) + } finally { + setIsProfileChanging(false) + } + } + // Get capabilities that need configuration (have missing keys) const getCapabilitiesNeedingSetup = (): CapabilityRequirement[] => { if (!quickstartConfig || !quickstartConfig.required_capabilities) return [] @@ -132,6 +208,23 @@ function QuickstartWizardContent() { const result = await saveToApi(quickstartApi.saveConfig) if (result.success) { + // Mark configured providers as installed so they appear in wiring board + const providerIds = (quickstartConfig?.required_capabilities ?? []) + .map((cap: CapabilityRequirement) => cap.selected_provider) + .filter((id): id is string => !!id) + const uniqueProviderIds = [...new Set(providerIds)] + if (uniqueProviderIds.length > 0) { + try { + const currentConfig = await settingsApi.getConfig() + const currentInstalled: string[] = currentConfig.data?.installed_services ?? [] + const newInstalled = [...new Set([...currentInstalled, ...uniqueProviderIds])] + if (newInstalled.length > currentInstalled.length) { + await settingsApi.update({ installed_services: newInstalled }) + } + } catch (err) { + console.warn('Failed to update installed_services after quickstart save:', err) + } + } updateApiKeysStatus(true) setMessage({ type: 'success', text: 'Configuration saved!' }) setIsSubmitting(false) @@ -143,26 +236,35 @@ function QuickstartWizardContent() { } } - // Container management + // Container management - using v2 deployment API const checkContainerStatuses = async () => { + if (!deployTarget) { + console.warn('[QuickstartWizard] No deployment target available for status check') + return + } + try { - const response = await servicesApi.getInstalled() - const servicesList = response.data + // Get all deployments for the local leader + const response = await deploymentsApi.listDeployments({ + unode_hostname: deployTarget.identifier + }) + const deployments = response.data - console.log('[QuickstartWizard] Docker services from API:', servicesList.map((s: any) => ({ name: s.name, status: s.status }))) + console.log('[QuickstartWizard] Deployments from API:', deployments.map((d: Deployment) => ({ service_id: d.service_id, status: d.status }))) console.log('[QuickstartWizard] Containers to check:', containers.map(c => c.name)) setContainers((prev) => prev.map((container) => { - // Match by exact name only - avoid false positives from partial matches - const serviceInfo = servicesList.find( - (s) => s.service_name === container.name + // Find deployment for this service + // Note: deployment service_id includes compose file prefix, so we need to match by service name + const deployment = deployments.find((d: Deployment) => + d.service_id.includes(container.name) ) - console.log(`[QuickstartWizard] Matching ${container.name}:`, serviceInfo ? { name: serviceInfo.service_name, status: serviceInfo.status } : 'NOT FOUND') + console.log(`[QuickstartWizard] Matching ${container.name}:`, deployment ? { service_id: deployment.service_id, status: deployment.status } : 'NOT FOUND') - if (serviceInfo) { - const isRunning = serviceInfo.status === 'running' + if (deployment) { + const isRunning = deployment.status === 'running' return { ...container, status: isRunning ? 'running' : 'stopped', @@ -177,6 +279,11 @@ function QuickstartWizardContent() { } const startContainer = async (containerName: string) => { + if (!deployTarget) { + setMessage({ type: 'error', text: 'No deployment target available' }) + return + } + setContainers((prev) => prev.map((c) => (c.name === containerName ? { ...c, status: 'starting' } : c)) ) @@ -186,72 +293,95 @@ function QuickstartWizardContent() { const displayName = container?.displayName || containerName try { - await servicesApi.startService(containerName) + // Use v2 deployment API - deploy service to local leader + // The service_id should match the service name from quickstart config + await deploymentsApi.deploy(containerName, deployTarget.identifier) + + setMessage({ type: 'info', text: `Deploying ${displayName}... (pulling images if needed)` }) - // Poll for status - longer timeout for slower containers + // Poll for deployment status - longer timeout for image pulls let attempts = 0 - const maxAttempts = 30 // 60 seconds total + const maxAttempts = 60 // 120 seconds total (2 minutes for image pulls) const pollStatus = async () => { attempts++ try { - const response = await servicesApi.getDockerDetails(containerName) - console.log(`[QuickstartWizard] Poll ${containerName} attempt ${attempts}:`, response.data) - const isRunning = response.data.status === 'running' + if (!deployTarget) return + + // Get deployments for this service + const response = await deploymentsApi.listDeployments({ + unode_hostname: deployTarget.identifier + }) + + // Find this service's deployment + const deployment = response.data.find((d: Deployment) => + d.service_id.includes(containerName) + ) + + console.log(`[QuickstartWizard] Poll ${containerName} attempt ${attempts}:`, deployment?.status) - if (isRunning) { + if (deployment && deployment.status === 'running') { setContainers((prev) => prev.map((c) => (c.name === containerName ? { ...c, status: 'running' } : c)) ) - // Update wizard context with service status (uses service name directly) + // Update wizard context with service status updateServiceStatus(containerName, { configured: true, running: true }) setMessage({ type: 'success', text: `${displayName} started successfully!` }) return } - if (attempts < maxAttempts) { - setTimeout(pollStatus, 2000) - } else { - // Final check - container might be running but API was slow - // Mark as running anyway since the start command succeeded + if (deployment && deployment.status === 'failed') { setContainers((prev) => prev.map((c) => c.name === containerName - ? { ...c, status: 'running' } // Assume running after timeout + ? { ...c, status: 'error', error: 'Deployment failed' } : c ) ) - updateServiceStatus(containerName, { configured: true, running: true }) - setMessage({ type: 'info', text: `${displayName} started (status check timed out)` }) + setMessage({ type: 'error', text: `${displayName} deployment failed` }) + return } - } catch (err) { + if (attempts < maxAttempts) { setTimeout(pollStatus, 2000) } else { - // Even on error, assume it started since the start command succeeded - setContainers((prev) => - prev.map((c) => - c.name === containerName ? { ...c, status: 'running' } : c + // Timeout - check final status + if (deployment && deployment.status === 'deploying') { + // Still deploying - probably pulling large images + setContainers((prev) => + prev.map((c) => + c.name === containerName + ? { ...c, status: 'running' } // Assume it will succeed + : c + ) ) - ) - updateServiceStatus(containerName, { configured: true, running: true }) - setMessage({ type: 'info', text: `${displayName} started (status check failed)` }) + updateServiceStatus(containerName, { configured: true, running: true }) + setMessage({ type: 'info', text: `${displayName} deployment in progress (may take a few more minutes)` }) + } else { + setMessage({ type: 'info', text: `${displayName} status check timed out` }) + } + } + } catch (err) { + if (attempts < maxAttempts) { + setTimeout(pollStatus, 2000) + } else { + setMessage({ type: 'error', text: `Failed to check ${displayName} status` }) } } } - setTimeout(pollStatus, 2000) + setTimeout(pollStatus, 3000) // Start polling after 3 seconds } catch (error) { setContainers((prev) => prev.map((c) => c.name === containerName - ? { ...c, status: 'error', error: getErrorMessage(error, 'Failed to start') } + ? { ...c, status: 'error', error: getErrorMessage(error, 'Failed to deploy') } : c ) ) - setMessage({ type: 'error', text: getErrorMessage(error, `Failed to start ${displayName}`) }) + setMessage({ type: 'error', text: getErrorMessage(error, `Failed to deploy ${displayName}`) }) } } @@ -342,6 +472,11 @@ function QuickstartWizardContent() { containers={containers} onStart={startContainer} onRefresh={checkContainerStatuses} + profiles={quickstartConfig?.profiles ?? []} + activeProfile={quickstartConfig?.active_profile ?? null} + onProfileSelect={handleProfileSelect} + isProfileChanging={isProfileChanging} + onOpenWizard={(wizardId) => navigate(`/wizard/${wizardId}`)} /> )} @@ -375,9 +510,25 @@ interface StartServicesStepProps { containers: ContainerInfo[] onStart: (name: string) => void onRefresh: () => void + profiles: ServiceProfile[] + activeProfile: string | null + onProfileSelect: (name: string) => Promise + isProfileChanging: boolean + onOpenWizard: (wizardId: string) => void } -function StartServicesStep({ containers, onStart, onRefresh }: StartServicesStepProps) { +function StartServicesStep({ + containers, + onStart, + onRefresh, + profiles, + activeProfile, + onProfileSelect, + isProfileChanging, + onOpenWizard, +}: StartServicesStepProps) { + const activeProfileData = profiles.find((p) => p.name === activeProfile) + return (
@@ -386,7 +537,7 @@ function StartServicesStep({ containers, onStart, onRefresh }: StartServicesStep Start Core Services

- Start the OpenMemory and Chronicle containers to enable the web client. + Choose a service profile, then start the containers to enable the web client.

+ {/* Profile picker */} + {profiles.length > 0 && ( +
+

Service Profile

+
+ {profiles.map((profile) => { + const isActive = profile.name === activeProfile + return ( + + ) + })} +
+ + {/* Wizard link for active profile */} + {activeProfileData?.wizard && ( +
+
+

+ This profile has a guided setup wizard with more options. +

+ +
+
+ )} +
+ )} +
{containers.map((container) => ( (null) const [isCheckingModels, setIsCheckingModels] = useState(false) const [tokenSaved, setTokenSaved] = useState(false) + const [deployTarget, setDeployTarget] = useState(null) // Container state for speaker-recognition service const [containerStatus, setContainerStatus] = useState({ @@ -75,11 +76,22 @@ export default function SpeakerRecognitionWizard() { mode: 'onChange', }) - // Load existing configuration + // Load existing configuration and deploy target useEffect(() => { loadExistingConfig() + loadDeployTarget() }, []) + const loadDeployTarget = async () => { + try { + const response = await deploymentsApi.listTargets() + const leader = response.data.find((t: DeployTarget) => t.type === 'docker' && t.is_leader) + if (leader) setDeployTarget(leader) + } catch (error) { + console.error('Failed to load deployment targets:', error) + } + } + // Check model access when entering model access step useEffect(() => { if (wizard.currentStep.id === 'models' && tokenSaved) { @@ -87,12 +99,12 @@ export default function SpeakerRecognitionWizard() { } }, [wizard.currentStep.id, tokenSaved]) - // Check container status when entering start_container step + // Check container status when entering start_container step (or when deployTarget becomes available) useEffect(() => { - if (wizard.currentStep.id === 'start_container') { + if (wizard.currentStep.id === 'start_container' && deployTarget) { checkContainerStatus() } - }, [wizard.currentStep.id]) + }, [wizard.currentStep.id, deployTarget]) const loadExistingConfig = async () => { try { @@ -125,54 +137,54 @@ export default function SpeakerRecognitionWizard() { } } - // Check container status + // Check container status via deploymentsApi const checkContainerStatus = async () => { + if (!deployTarget) return try { - const response = await servicesApi.getDockerDetails('speaker-recognition') - const isRunning = response.data.status === 'running' + const response = await deploymentsApi.listDeployments({ unode_hostname: deployTarget.identifier }) + const deployment = (response.data as Deployment[]).find((d) => d.service_id.includes('speaker-recognition')) setContainerStatus((prev) => ({ ...prev, - status: isRunning ? 'running' : 'stopped', + status: deployment?.status === 'running' ? 'running' : 'stopped', error: undefined, })) } catch (error) { - // Service might not exist yet - that's expected - setContainerStatus((prev) => ({ - ...prev, - status: 'stopped', - error: undefined, - })) + setContainerStatus((prev) => ({ ...prev, status: 'stopped', error: undefined })) } } - // Start the speaker-recognition container + // Start the speaker-recognition container via deploymentsApi const startContainer = async () => { + if (!deployTarget) { + setMessage({ type: 'error', text: 'No deployment target available' }) + return + } setContainerStatus((prev) => ({ ...prev, status: 'starting', error: undefined })) try { - // First install the service (this creates the container if needed) - await servicesApi.install('speaker-recognition') - - // Then start it - await servicesApi.startService('speaker-recognition') + await deploymentsApi.deploy('speaker-recognition', deployTarget.identifier) - // Poll for running status let attempts = 0 - const maxAttempts = 30 // Longer timeout for speaker-rec (model download) + const maxAttempts = 60 // Longer timeout for speaker-rec (model download) const pollStatus = async () => { attempts++ try { - const response = await servicesApi.getDockerDetails('speaker-recognition') - const isRunning = response.data.status === 'running' + const response = await deploymentsApi.listDeployments({ unode_hostname: deployTarget.identifier }) + const deployment = (response.data as Deployment[]).find((d) => d.service_id.includes('speaker-recognition')) - if (isRunning) { + if (deployment?.status === 'running') { setContainerStatus((prev) => ({ ...prev, status: 'running', error: undefined })) updateServiceStatus('speaker-recognition', { configured: true, running: true }) setMessage({ type: 'success', text: 'Speaker Recognition service started!' }) return } + if (deployment?.status === 'failed') { + setContainerStatus((prev) => ({ ...prev, status: 'error', error: 'Deployment failed' })) + return + } + if (attempts < maxAttempts) { setTimeout(pollStatus, 2000) } else { @@ -183,9 +195,7 @@ export default function SpeakerRecognitionWizard() { })) } } catch (err) { - if (attempts < maxAttempts) { - setTimeout(pollStatus, 2000) - } + if (attempts < maxAttempts) setTimeout(pollStatus, 2000) } } diff --git a/ushadow/frontend/src/wizards/TailscaleOperatorWizard.tsx b/ushadow/frontend/src/wizards/TailscaleOperatorWizard.tsx new file mode 100644 index 00000000..6bca9ec3 --- /dev/null +++ b/ushadow/frontend/src/wizards/TailscaleOperatorWizard.tsx @@ -0,0 +1,730 @@ +import { useState, useEffect, useRef } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { + CheckCircle, + Loader2, + Shield, + ExternalLink, + RefreshCw, + AlertTriangle, + Play, + Copy, + Tag, +} from 'lucide-react' +import { kubernetesApi } from '../services/api' +import type { TailscaleOperatorStatus, TailscaleProxyGroup } from '../services/api' +import { useWizardSteps } from '../hooks/useWizardSteps' +import { WizardShell } from '../components/wizard' +import type { WizardMessage } from '../components/wizard' +import type { WizardStep } from '../types/wizard' + +const STEPS: WizardStep[] = [ + { id: 'acl', label: 'ACL' }, + { id: 'oauth', label: 'OAuth' }, + { id: 'install', label: 'Install' }, + { id: 'configure', label: 'Configure' }, + { id: 'complete', label: 'Done' }, +] + +const ACL_TAG_SNIPPET = `"tagOwners": { + "tag:k8s-operator": [] +}` + +const ACL_AUTOAPPROVERS_SNIPPET = `"autoApprovers": { + "services": { + "tag:k8s-operator": ["tag:k8s-operator"] + } +}` + +export default function TailscaleOperatorWizard() { + const navigate = useNavigate() + const [searchParams] = useSearchParams() + const clusterId = searchParams.get('cluster') ?? '' + + const wizard = useWizardSteps(STEPS) + const [clusterName, setClusterName] = useState('') + const [clientId, setClientId] = useState('') + const [clientSecret, setClientSecret] = useState('') + const [hostname, setHostname] = useState('ushadow-chakra') + const [status, setStatus] = useState(null) + const [message, setMessage] = useState(null) + const [installing, setInstalling] = useState(false) + const [configuring, setConfiguring] = useState(false) + const [saving, setSaving] = useState(false) + const [copied, setCopied] = useState<'tag' | 'autoapprovers' | null>(null) + const [detectedGroups, setDetectedGroups] = useState([]) + const [selectedGroup, setSelectedGroup] = useState('') // '' = create new + const [loadingGroups, setLoadingGroups] = useState(false) + const pollRef = useRef | null>(null) + + useEffect(() => { + if (!clusterId) return + const load = async () => { + try { + const [clusterResp, credsResp] = await Promise.all([ + kubernetesApi.getCluster(clusterId), + kubernetesApi.getTailscaleOperatorCredentials(), + ]) + setClusterName(clusterResp.data.name) + const defaultHostname = `ushadow-${clusterResp.data.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}` + if (credsResp.data.client_id) setClientId(credsResp.data.client_id) + if (credsResp.data.client_secret) setClientSecret(credsResp.data.client_secret) + // Saved hostname wins; fall back to cluster-derived default + setHostname(credsResp.data.hostname || defaultHostname) + } catch { /* ignore */ } + } + load() + }, [clusterId]) + + useEffect(() => { + const polledSteps = ['install', 'configure', 'complete'] + if (polledSteps.includes(wizard.currentStep.id)) { + checkStatus() + pollRef.current = setInterval(checkStatus, 5000) + } + return () => { if (pollRef.current) clearInterval(pollRef.current) } + }, [wizard.currentStep.id]) + + // Detect existing ProxyGroups when entering the install step + useEffect(() => { + if (wizard.currentStep.id !== 'install' || !clusterId) return + setLoadingGroups(true) + kubernetesApi.listTailscaleProxyGroups(clusterId) + .then(r => { + setDetectedGroups(r.data) + // Auto-select the first found group (prefer the one already used by status) + if (r.data.length > 0 && !selectedGroup) { + setSelectedGroup(r.data[0].name) + } + }) + .catch(() => {}) + .finally(() => setLoadingGroups(false)) + }, [wizard.currentStep.id, clusterId]) + + const checkStatus = async () => { + if (!clusterId) return + try { + const r = await kubernetesApi.getTailscaleOperatorStatus(clusterId) + setStatus(r.data) + if (r.data.install_error || (r.data.operator_ready && r.data.ingress_annotated)) { + setInstalling(false) + } + } catch { /* ignore polling errors */ } + } + + // Prefer the annotation-based hostname when ts_hostname is stale (e.g. after a + // hostname change — the LoadBalancer status lags until the operator re-provisions). + const annotationUrl = (status?.hostname && status?.tailnet_domain) + ? `https://${status.hostname}.${status.tailnet_domain}` + : null + const tsHostnameMatchesAnnotation = status?.ts_hostname?.startsWith(`${status?.hostname}.`) + const tsUrl = (status?.ts_hostname && tsHostnameMatchesAnnotation) + ? `https://${status.ts_hostname}` + : annotationUrl ?? (status?.ts_hostname ? `https://${status.ts_hostname}` : null) + + const handleInstall = async () => { + if (!clusterId || !clientId || !clientSecret) return + setInstalling(true) + // Optimistically clear install completion so polling doesn't immediately + // flip installing=false when the old ProxyGroup is still present. + setStatus(prev => prev ? { ...prev, install_error: null, ingress_annotated: false, ts_hostname: null } : null) + try { + // Save credentials (including potentially-updated hostname) before installing + await saveCredentials() + await kubernetesApi.installTailscaleOperator(clusterId, { + client_id: clientId, + client_secret: clientSecret, + hostname, + proxygroup_name: selectedGroup, // '' = create new + }) + } catch (e: unknown) { + setInstalling(false) + const msg = e instanceof Error ? e.message : 'Install request failed' + setMessage({ type: 'error', text: msg }) + } + } + + const handleConfigure = async () => { + if (!clusterId || !tsUrl) return + const effectiveHostname = status?.ts_hostname ?? (status?.hostname && status?.tailnet_domain ? `${status.hostname}.${status.tailnet_domain}` : null) + if (!effectiveHostname) return + setConfiguring(true) + setMessage(null) + try { + await kubernetesApi.configureTailscaleIngress(clusterId, effectiveHostname) + await checkStatus() + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Configure failed' + setMessage({ type: 'error', text: msg }) + } finally { + setConfiguring(false) + } + } + + const saveCredentials = async () => { + try { + await kubernetesApi.saveTailscaleOperatorCredentials({ client_id: clientId, client_secret: clientSecret, hostname }) + } catch (e) { + console.error('[TailscaleWizard] saveCredentials failed:', e) + throw e + } + } + + const handleNext = async () => { + setMessage(null) + if (wizard.currentStep.id === 'oauth') { + setSaving(true) + try { + await saveCredentials() + } catch (e: unknown) { + setSaving(false) + const msg = e instanceof Error ? e.message : 'Failed to save credentials' + setMessage({ type: 'error', text: `Save failed: ${msg}` }) + return + } + setSaving(false) + } + wizard.next() + } + + const copySnippet = async (which: 'tag' | 'autoapprovers') => { + const text = which === 'tag' ? ACL_TAG_SNIPPET : ACL_AUTOAPPROVERS_SNIPPET + await navigator.clipboard.writeText(text) + setCopied(which) + setTimeout(() => setCopied(null), 2000) + } + + const isInstallComplete = !!status?.operator_ready && !!status?.ingress_annotated + const hasCredentials = !!(clientId && clientSecret) + + return ( + { + const idx = STEPS.findIndex(s => s.id === id) + if (idx <= wizard.currentIndex) { setMessage(null); wizard.goTo(id) } + }} + isFirstStep={wizard.isFirst} + onBack={() => { setMessage(null); wizard.back() }} + onNext={wizard.currentStep.id === 'complete' ? undefined : handleNext} + nextDisabled={ + saving || + (wizard.currentStep.id === 'oauth' && !hasCredentials) || + (wizard.currentStep.id === 'install' && !isInstallComplete) || + (wizard.currentStep.id === 'configure' && !(status?.ingress_configured && status?.deployment_configured)) + } + nextLoading={saving} + message={message} + headerActions={ + + } + > + + {/* ── Step 1: ACL Policy ── */} + {wizard.currentStep.id === 'acl' && ( +
+
+

+ Update your tailnet ACL policy +

+

+ Two entries are required in your Tailscale ACL before the operator can work. + Add both, then click Next. +

+
+ + {/* Snippet 1: tagOwners */} +
+
+ +

+ 1. Declare the tag:k8s-operator tag +

+
+

+ Every proxy device the operator creates is tagged with this label. + An empty tagOwners array means + no individual user owns it — it's machine-owned. +

+
+
+                {ACL_TAG_SNIPPET}
+              
+ +
+
+ + {/* Snippet 2: autoApprovers */} +
+
+ +

+ 2. Allow the operator to advertise VIP services +

+
+

+ Without this, proxy pods send service registrations to the control plane but they're + silently ignored — services won't get Tailscale hostnames even if everything else is working. +

+
+
+                {ACL_AUTOAPPROVERS_SNIPPET}
+              
+ +
+

+ If you already have an autoApprovers block, + add the services key inside it. +

+
+ + + Open Tailscale ACL editor + + + +

+ Save the ACL policy with both changes, then click Next. +

+
+ )} + + {/* ── Step 2: OAuth Client ── */} + {wizard.currentStep.id === 'oauth' && ( +
+
+

+ Create a Tailscale OAuth client +

+

+ The operator needs long-lived OAuth credentials to create auth keys for each proxy + it manages. A one-time auth key won't work here. +

+
+ +
+

+ When creating the OAuth client, enable: +

+
    +
  • + +
    + Core — Devices + read & write +
    +
  • +
  • + +
    + Keys — Auth Keys + write — needed to create device auth keys +
    +
  • +
  • + +
    + Tags + + add tag:k8s-operator + +
    +
  • +
+ + Open Tailscale OAuth settings + + +
+ + {hasCredentials && ( +
+ +

+ You have saved credentials. If the operator previously crashed with a{' '} + 403 error, your OAuth client is missing the Keys scope. + Scopes can't be added after creation — create a new client with both Core + Keys. +

+
+ )} + +
+
+
+ + setClientId(e.target.value)} + onBlur={saveCredentials} + placeholder="tskey-client-..." + className="w-full px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-mono text-xs" + data-testid="ts-operator-client-id" + /> +
+
+ + setClientSecret(e.target.value)} + onBlur={saveCredentials} + placeholder="tskey-client-secret-..." + className="w-full px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-mono text-xs" + data-testid="ts-operator-client-secret" + /> +
+
+
+ + setHostname(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'))} + onBlur={saveCredentials} + placeholder="ushadow-chakra" + className="w-full px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-mono text-xs" + data-testid="ts-operator-hostname" + /> +

+ Your cluster will be reachable at{' '} + {`${hostname || 'ushadow-chakra'}..ts.net`} +

+
+

+ Credentials are saved to{' '} + secrets.yaml as you type. +

+
+
+ )} + + {/* ── Step 3: Install ── */} + {wizard.currentStep.id === 'install' && ( +
+
+

+ Install the operator +

+ {clusterName && ( +

+ Target cluster: {clusterName} +

+ )} +
+ + {/* ProxyGroup picker */} +
+

+ ProxyGroup {loadingGroups ? '(detecting…)' : ''} +

+ {detectedGroups.length > 0 ? ( +
+ {detectedGroups.map(pg => ( + + ))} + +
+ ) : !loadingGroups ? ( +

+ No existing ProxyGroup found — a new one will be created. +

+ ) : null} +
+ +
+ {status?.install_error ? ( +
+

Install failed:

+
+                  {status.install_error}
+                
+ +
+ ) : installing ? ( +
+ + + Installing… this may take up to 2 minutes + +
+ ) : isInstallComplete ? ( +
+
+ + Already installed — click Next to continue +
+ +
+ ) : ( + + )} +
+ +
+ + + + {status?.ts_hostname && ( + + )} +
+ +
+ {isInstallComplete ? ( + <> + + Ready — click Next + + ) : ( + <> + + Polling every 5 seconds... + + + )} +
+ + {!clusterId && ( +
+ +

+ No cluster selected — open this wizard from a cluster card. +

+
+ )} +
+ )} + + {/* ── Step 4: Configure ── */} + {wizard.currentStep.id === 'configure' && ( +
+
+

+ Configure ushadow for Tailscale +

+

+ Update the backend URL — routing and TLS are handled automatically by the ProxyGroup. +

+
+ + {tsUrl && ( +
+

+ Will configure ushadow to use:{' '} + {tsUrl} +

+
+ )} + +
+ + +
+ + {status?.ingress_configured && status?.deployment_configured ? ( +
+ + Configured — click Next to finish +
+ ) : ( + + )} + + {!tsUrl && ( +
+ +

+ Tailscale hostname not yet assigned. Go back to the Install step and wait for provisioning. +

+
+ )} +
+ )} + + {/* ── Step 5: Complete ── */} + {wizard.currentStep.id === 'complete' && ( +
+
+ +
+

+ Operator Installed! +

+

+ Your cluster now has a Tailscale-backed trusted HTTPS certificate +

+
+
+ + {tsUrl && ( + + )} + +
+

What was done:

+
    +
  • Tailscale operator installed in tailscale-system
  • +
  • ProxyGroup + Tailscale Ingress created (HA, routes /api and / directly to services)
  • +
  • TLS certificates managed automatically by the operator
  • +
  • USHADOW_PUBLIC_URL updated in the backend deployment
  • +
+

Remaining step:

+

+ The backend pod will restart automatically to pick up the new URL. + Once it's running, scan a fresh QR code from the mobile app. +

+
+ + +
+ )} +
+ ) +} + +function StatusRow({ label, done }: { label: string; done: boolean }) { + return ( +
+ {done + ? + :
} + {label} +
+ ) +} diff --git a/ushadow/frontend/src/wizards/TailscaleWizard.tsx b/ushadow/frontend/src/wizards/TailscaleWizard.tsx index 74dc78e6..63672719 100644 --- a/ushadow/frontend/src/wizards/TailscaleWizard.tsx +++ b/ushadow/frontend/src/wizards/TailscaleWizard.tsx @@ -14,9 +14,10 @@ import { AlertTriangle, Trash2, } from 'lucide-react' -import { tailscaleApi, TailscaleConfig, ContainerStatus, AuthUrlResponse, TailnetSettings } from '../services/api' +import { tailscaleApi, settingsApi, TailscaleConfig, ContainerStatus, AuthUrlResponse, TailnetSettings } from '../services/api' import { useWizardSteps } from '../hooks/useWizardSteps' import { useWizard } from '../contexts/WizardContext' +import { useSettings } from '../contexts/SettingsContext' import { useFeatureFlags } from '../contexts/FeatureFlagsContext' import { WizardShell, WizardMessage, WhatsNext } from '../components/wizard' import type { WizardStep } from '../types/wizard' @@ -51,6 +52,7 @@ const OS_INSTALL_INFO = { export default function TailscaleWizard() { const navigate = useNavigate() const { updateServiceStatus, markPhaseComplete } = useWizard() + const { refreshSettings } = useSettings() const { isEnabled } = useFeatureFlags() // Check if Caddy routing is enabled via feature flag @@ -98,6 +100,13 @@ export default function TailscaleWizard() { loading: boolean }>({ updated: false, loading: false }) + // Casdoor registration status + const [authStatus, setAuthStatus] = useState<{ + registered: boolean + message?: string + checked: boolean + }>({ registered: false, checked: false }) + // Configured routes (returned from configure-serve) const [configuredRoutes, setConfiguredRoutes] = useState('') @@ -125,15 +134,61 @@ export default function TailscaleWizard() { }, [wizard.currentStep.id]) // ============================================================================ - // Provision Step: Check Tailnet Settings + // Provision Step: Check Tailnet Settings & Register with Casdoor // ============================================================================ useEffect(() => { if (wizard.currentStep.id === 'provision' && containerStatus?.authenticated) { checkTailnetSettings() + registerWithCasdoor() } }, [wizard.currentStep.id, containerStatus?.authenticated]) + const registerWithCasdoor = async () => { + // Register Casdoor callback URLs as soon as we land on provision page + try { + let hostname = config.hostname + if (!hostname) { + // Try to get hostname from container status + const statusResponse = await tailscaleApi.getContainerStatus() + if (statusResponse.data.hostname) { + hostname = statusResponse.data.hostname + setConfig(prev => ({ ...prev, hostname })) + } else { + // No hostname yet, skip Casdoor registration + return + } + } + + // Call configure-serve to register with Casdoor and configure routes + // This is idempotent and safe to call multiple times + const finalConfig = { ...config, hostname } + const serveResponse = await tailscaleApi.configureServe(finalConfig) + + // Capture Casdoor registration status + setAuthStatus({ + registered: serveResponse.data.casdoor_registered ?? false, + message: serveResponse.data.casdoor_message, + checked: true + }) + + // Save configured routes for display + if (serveResponse.data.routes) { + setConfiguredRoutes(serveResponse.data.routes) + } + + console.log('Casdoor registration completed:', serveResponse.data) + } catch (err) { + // Non-critical error - just log it + console.log('Casdoor registration failed (non-critical):', err) + setAuthStatus({ + registered: false, + message: 'Failed to connect to backend', + checked: true + }) + } + } + const checkTailnetSettings = async () => { try { const response = await tailscaleApi.getTailnetSettings() @@ -145,12 +200,12 @@ export default function TailscaleWizard() { } // ============================================================================ - // Complete Step: Update CORS origins + // Complete Step: Update CORS origins and auth settings // ============================================================================ useEffect(() => { if (wizard.currentStep.id === 'complete' && config.hostname && !corsStatus.updated && !corsStatus.loading) { - updateCorsOrigins() + updateCorsOriginsAndSettings() } }, [wizard.currentStep.id, config.hostname]) @@ -172,23 +227,35 @@ export default function TailscaleWizard() { } } - const updateCorsOrigins = async () => { + const updateCorsOriginsAndSettings = async () => { if (!config.hostname) return setCorsStatus({ updated: false, loading: true }) try { - // Call dedicated CORS update endpoint (doesn't touch Caddy routes) + // Update CORS origins to allow Tailscale hostname + console.log('[TailscaleWizard] Updating CORS origins for:', config.hostname) const response = await tailscaleApi.updateCorsOrigins(config.hostname) + console.log('[TailscaleWizard] CORS update response:', response.data) + + // Note: Auth URL is now determined dynamically based on origin + // (see auth/config.ts) so no settings update needed + + // Success - update UI state setCorsStatus({ updated: true, - origin: response.data.origin, + origin: response.data?.origin || `https://${config.hostname}`, loading: false }) + console.log('[TailscaleWizard] CORS update complete') } catch (err) { - console.error('Failed to update CORS:', err) + console.error('[TailscaleWizard] Failed to update CORS and settings:', err) + console.error('[TailscaleWizard] Error details:', { + message: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined + }) setCorsStatus({ updated: false, - error: 'Failed to update CORS origins', + error: err instanceof Error ? err.message : 'Failed to update CORS origins and settings', loading: false }) } @@ -589,6 +656,13 @@ export default function TailscaleWizard() { setConfiguredRoutes(serveResponse.data.routes) } + // Capture Casdoor registration status + setAuthStatus({ + registered: serveResponse.data.casdoor_registered ?? false, + message: serveResponse.data.casdoor_message, + checked: true + }) + // Step 2: Provision the certificate (HTTPS is now enabled) setMessage({ type: 'info', @@ -1194,17 +1268,71 @@ export default function TailscaleWizard() { )} {certificateProvisioned ? ( -
- -
-

- HTTPS Access Configured! -

-

- Certificates provisioned and routing configured for {config.hostname} -

+ <> +
+ +
+

+ HTTPS Access Configured! +

+

+ Certificates provisioned and routing configured for {config.hostname} +

+
-
+ + {/* Casdoor Registration Status */} + {authStatus.checked ? ( +
+ {authStatus.registered ? ( + + ) : ( + + )} +
+

+ {authStatus.registered ? 'Casdoor OAuth Configured' : 'Casdoor Configuration Skipped'} +

+

+ {authStatus.message || (authStatus.registered + ? 'OAuth login enabled for Tailscale domain' + : 'OAuth login may require backend restart or manual Casdoor configuration')} +

+
+
+ ) : ( +
+ +
+

+ Checking Casdoor Configuration... +

+

+ Registering OAuth callback URLs for Tailscale domain +

+
+
+ )} + ) : (
)} diff --git a/ushadow/frontend/src/wizards/index.ts b/ushadow/frontend/src/wizards/index.ts index 7804c0a6..e3c9f429 100644 --- a/ushadow/frontend/src/wizards/index.ts +++ b/ushadow/frontend/src/wizards/index.ts @@ -14,7 +14,7 @@ export { default as QuickstartWizard } from './QuickstartWizard' export { default as LocalServicesWizard } from './LocalServicesWizard' export { default as MobileAppWizard } from './MobileAppWizard' export { default as SpeakerRecognitionWizard } from './SpeakerRecognitionWizard' -export { default as MyceliaWizard } from './MyceliaWizard' + // Export wizard registry for dynamic discovery export { wizardRegistry, getAllWizards, getWizardById } from './registry' diff --git a/ushadow/frontend/src/wizards/registry.ts b/ushadow/frontend/src/wizards/registry.ts index 75ed584c..d8de0a82 100644 --- a/ushadow/frontend/src/wizards/registry.ts +++ b/ushadow/frontend/src/wizards/registry.ts @@ -5,7 +5,7 @@ * Used by WizardStartPage to dynamically list all wizards. */ -import { LucideIcon, Sparkles, Shield, Smartphone, Mic, CheckCircle2, Wand2, Server, MessageSquare, Brain, Database } from 'lucide-react' +import { LucideIcon, Sparkles, Shield, Smartphone, Mic, CheckCircle2, Wand2, Server, MessageSquare, Brain } from 'lucide-react' export interface WizardMetadata { id: string @@ -88,13 +88,6 @@ export const wizardRegistry: WizardMetadata[] = [ description: 'OpenMemory setup', icon: Brain, }, - { - id: 'mycelia', - path: '/wizard/mycelia', - label: 'Mycelia', - description: 'AI memory and timeline', - icon: Database, - }, ] /** Icon to show when all wizards are complete */ diff --git a/ushadow/frontend/tailwind.config.js b/ushadow/frontend/tailwind.config.js index 078bacd2..53c62ec4 100644 --- a/ushadow/frontend/tailwind.config.js +++ b/ushadow/frontend/tailwind.config.js @@ -171,5 +171,7 @@ export default { }, }, }, - plugins: [], + plugins: [ + require('@tailwindcss/typography'), + ], } diff --git a/ushadow/frontend/vite.config.ts b/ushadow/frontend/vite.config.ts index 3ab5c6a5..a701fb82 100644 --- a/ushadow/frontend/vite.config.ts +++ b/ushadow/frontend/vite.config.ts @@ -4,6 +4,11 @@ import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + resolve: { + // Force a single React instance even when packages bundle their own copy (e.g., vibe-kanban-web-companion). + // Without this, two React instances coexist and hooks throw "Cannot read properties of null (reading 'useState')". + dedupe: ['react', 'react-dom'], + }, server: { port: 5173, host: '0.0.0.0', diff --git a/ushadow/launcher/.claude/hooks/idle-notification.sh b/ushadow/launcher/.claude/hooks/idle-notification.sh new file mode 100755 index 00000000..e0f86308 --- /dev/null +++ b/ushadow/launcher/.claude/hooks/idle-notification.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Claude Code Notification hook - fires on idle_prompt +# Move ticket to in_review when agent is waiting for user input + +# Log for debugging +echo "[$(date)] idle-notification hook fired in $(pwd)" >> /tmp/claude-kanban-hooks.log + +# Use worktree path (more reliable than branch name) +WORKTREE_PATH="$(pwd)" + +if command -v kanban-cli >/dev/null 2>&1; then + kanban-cli move-to-review "$WORKTREE_PATH" 2>/dev/null + echo "[$(date)] Moved ticket at $WORKTREE_PATH to review" >> /tmp/claude-kanban-hooks.log +fi + +exit 0 diff --git a/ushadow/launcher/.claude/hooks/kanban-status.sh b/ushadow/launcher/.claude/hooks/kanban-status.sh new file mode 100755 index 00000000..59cfb98e --- /dev/null +++ b/ushadow/launcher/.claude/hooks/kanban-status.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Claude Code hook for automatic Kanban status updates +# +# This script is called by Claude Code hooks to automatically update +# ticket status based on agent activity. + +# Get current branch name +BRANCH=$(git branch --show-current 2>/dev/null) + +if [ -z "$BRANCH" ]; then + # Not in a git repository or no branch, skip + exit 0 +fi + +# Function to update status via kanban-cli +update_status() { + local status="$1" + local command="$2" + + if command -v kanban-cli >/dev/null 2>&1; then + kanban-cli "$command" "$BRANCH" 2>/dev/null + fi +} + +# Determine which hook triggered this script +case "$CLAUDE_HOOK_NAME" in + "SessionStart") + # Agent session started - move to in_progress + update_status "in_progress" "move-to-progress" + ;; + "UserPromptSubmit") + # User just submitted a response - agent resuming work + update_status "in_progress" "move-to-progress" + ;; + "AssistantWaitingForUser") + # Agent is waiting for user input - move to in_review + update_status "in_review" "move-to-review" + ;; + "SessionEnd") + # Agent session ended - move to in_review + update_status "in_review" "move-to-review" + ;; +esac + +exit 0 diff --git a/ushadow/launcher/.claude/hooks/session-end.sh b/ushadow/launcher/.claude/hooks/session-end.sh new file mode 100755 index 00000000..09258c8d --- /dev/null +++ b/ushadow/launcher/.claude/hooks/session-end.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Claude Code SessionEnd hook - agent session ending +# Move ticket to in_review (waiting for human to review/respond) + +# Use worktree path (more reliable than branch name) +WORKTREE_PATH="$(pwd)" + +if command -v kanban-cli >/dev/null 2>&1; then + kanban-cli move-to-review "$WORKTREE_PATH" 2>/dev/null +fi + +exit 0 diff --git a/ushadow/launcher/.claude/hooks/session-start.sh b/ushadow/launcher/.claude/hooks/session-start.sh new file mode 100755 index 00000000..6a426a3a --- /dev/null +++ b/ushadow/launcher/.claude/hooks/session-start.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Claude Code SessionStart hook - agent session just started +# Move ticket to in_progress + +# Use worktree path (more reliable than branch name) +WORKTREE_PATH="$(pwd)" + +if command -v kanban-cli >/dev/null 2>&1; then + kanban-cli move-to-progress "$WORKTREE_PATH" 2>/dev/null +fi + +exit 0 diff --git a/ushadow/launcher/.claude/hooks/user-prompt-submit.sh b/ushadow/launcher/.claude/hooks/user-prompt-submit.sh new file mode 100755 index 00000000..faeb8143 --- /dev/null +++ b/ushadow/launcher/.claude/hooks/user-prompt-submit.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Claude Code UserPromptSubmit hook - user just submitted a prompt +# Move ticket to in_progress (agent resuming work after waiting) + +# Use worktree path (more reliable than branch name) +WORKTREE_PATH="$(pwd)" + +if command -v kanban-cli >/dev/null 2>&1; then + kanban-cli move-to-progress "$WORKTREE_PATH" 2>/dev/null +fi + +exit 0 diff --git a/ushadow/launcher/.claude/settings.local.json b/ushadow/launcher/.claude/settings.local.json new file mode 100644 index 00000000..2137fd44 --- /dev/null +++ b/ushadow/launcher/.claude/settings.local.json @@ -0,0 +1,61 @@ +{ + "permissions": { + "allow": [ + "Bash(git rebase:*)", + "Bash(git merge:*)", + "Bash(git checkout:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(kanban-cli:*)", + "Bash(xargs:*)", + "Bash(rustup target add:*)" + ] + }, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/session-start.sh", + "async": true + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/user-prompt-submit.sh", + "async": true + } + ] + } + ], + "Notification": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/idle-notification.sh", + "async": true + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/session-end.sh", + "async": true + } + ] + } + ] + } +} diff --git a/ushadow/launcher/AGENT_SELF_REPORTING.md b/ushadow/launcher/AGENT_SELF_REPORTING.md new file mode 100644 index 00000000..3e94c83e --- /dev/null +++ b/ushadow/launcher/AGENT_SELF_REPORTING.md @@ -0,0 +1,277 @@ +# Agent Self-Reporting - Kanban Status Updates + +This document explains how AI agents (like Claude Code) should automatically update Kanban ticket status as they work. + +## The Workflow + +### 1. Agent Starts Working (Human Provides Input) +```bash +kanban-cli move-to-progress "$BRANCH_NAME" +``` +**Status:** `in_progress` 🤖 + +**When:** Agent receives a user response and resumes work + +**Example:** +```bash +# User responds to agent's question +# Agent runs: +kanban-cli move-to-progress "generalLamcher" +# → All tickets for this branch move to "in_progress" +``` + +### 2. Agent Waits for Human Input +```bash +kanban-cli move-to-review "$BRANCH_NAME" +``` +**Status:** `in_review` 💬 + +**When:** Agent has a question or needs human input + +**Example:** +```bash +# Agent asks: "Should I use TypeScript or JavaScript?" +# Before showing the prompt, agent runs: +kanban-cli move-to-review "generalLamcher" +# → Tickets move to "in_review" (waiting for human) +``` + +### 3. Work is Merged (Automatic via Hook) +```bash +# This happens automatically when you run: +workmux merge +``` +**Status:** `done` ✅ + +**When:** Human runs `workmux merge` to merge the branch + +**Hook runs:** `kanban-cli move-to-done "$WM_BRANCH_NAME"` + +## Complete Example Flow + +```bash +# 1. User asks agent to implement a feature +# Agent starts working +kanban-cli move-to-progress "feature-auth" +# Status: in_progress 🤖 + +# 2. Agent encounters a decision point +# Agent asks: "Which database? PostgreSQL or MongoDB?" +kanban-cli move-to-review "feature-auth" +# Status: in_review 💬 + +# 3. User responds: "PostgreSQL" +# Agent resumes work +kanban-cli move-to-progress "feature-auth" +# Status: in_progress 🤖 + +# 4. Agent finishes, asks: "Ready to merge?" +kanban-cli move-to-review "feature-auth" +# Status: in_review 💬 + +# 5. User confirms and merges +workmux merge feature-auth +# Hook automatically runs: kanban-cli move-to-done "feature-auth" +# Status: done ✅ +``` + +## Implementation in Claude Code + +### Option 1: Manual Agent Commands + +The agent explicitly calls these commands at appropriate times: + +```bash +# In agent's workflow: +echo "[AGENT] Starting work on ticket..." +kanban-cli move-to-progress "$(git branch --show-current)" + +# ... do work ... + +# When needing input: +echo "[AGENT] Waiting for human response..." +kanban-cli move-to-review "$(git branch --show-current)" +``` + +### Option 2: Helper Scripts + +Source the helper script in your shell: + +```bash +# In ~/.zshrc or ~/.bashrc +source /path/to/launcher/kanban-status-helpers.sh + +# Then the agent can use: +kb-start # Start working (move to in_progress) +kb-waiting # Wait for human (move to in_review) +kb-status # Check current status +``` + +### Option 3: Agent Integration (Future) + +Ideally, the agent framework itself should call these: + +```python +# Pseudo-code for agent framework +class ClaudeAgent: + def on_user_input(self, message): + self.update_kanban_status("in_progress") + # ... process input ... + + def ask_user(self, question): + self.update_kanban_status("in_review") + # ... wait for response ... +``` + +## Environment Variables + +The agent should know which ticket it's working on. Set these: + +```bash +# When creating a worktree for a ticket +export TICKET_ID="ticket-abc-123" +export BRANCH_NAME="feature-auth" + +# Then the agent can use: +kanban-cli move-to-progress "$BRANCH_NAME" +``` + +## Detection Strategies + +### How Agent Knows When to Call + +**Starting Work (move to in_progress):** +- After receiving user's response to a question +- When user provides a new task/instruction +- When resuming from paused state + +**Waiting for Human (move to in_review):** +- Before calling `input()` or equivalent +- When presenting options/choices +- When asking for clarification +- When work is complete and awaiting approval + +**Example Patterns to Detect:** + +```python +# Python agent example +def ask_user(question): + # BEFORE asking: + run_command("kanban-cli move-to-review $(git branch --show-current)") + + # Now ask: + response = input(question) + + # AFTER receiving response: + run_command("kanban-cli move-to-progress $(git branch --show-current)") + + return response +``` + +## Workmux Hook Configuration + +The merge hook is already configured in `~/.config/workmux/config.yaml`: + +```yaml +pre_merge: + - kanban-cli move-to-done "$WM_BRANCH_NAME" +``` + +This runs automatically when you execute `workmux merge`. + +## Testing + +Test the full workflow: + +```bash +# 1. Start working +kanban-cli move-to-progress "generalLamcher" +kanban-cli find-by-branch "generalLamcher" +# Should show: in_progress + +# 2. Wait for human +kanban-cli move-to-review "generalLamcher" +kanban-cli find-by-branch "generalLamcher" +# Should show: in_review + +# 3. Resume work +kanban-cli move-to-progress "generalLamcher" +kanban-cli find-by-branch "generalLamcher" +# Should show: in_progress + +# 4. Merge (when ready) +workmux merge generalLamcher +# Should show: done +``` + +## Debugging + +### Check Current Status +```bash +kanban-cli find-by-branch "$(git branch --show-current)" +``` + +### View Database +```bash +sqlite3 ~/Library/Application\ Support/com.ushadow.launcher/kanban.db \ + "SELECT id, title, status, branch_name FROM tickets" +``` + +### Test Hook +```bash +# See what workmux will run +cat ~/.config/workmux/config.yaml + +# Test the command manually +export WM_BRANCH_NAME="test-branch" +kanban-cli move-to-done "$WM_BRANCH_NAME" +``` + +## Best Practices + +1. **Always use branch name as identifier** - Most reliable +2. **Call move-to-progress when resuming** - Keep status accurate +3. **Call move-to-review before every prompt** - Signal waiting state +4. **Let workmux handle "done"** - Don't manually mark as done +5. **Check status with find-by-branch** - Verify updates worked + +## Future Enhancements + +- [ ] Auto-detect agent activity (no manual calls needed) +- [ ] Integration with agent frameworks (LangChain, etc.) +- [ ] Tmux pane monitoring for automatic detection +- [ ] Web API for external integrations +- [ ] Slack/Discord notifications on status changes + +## Troubleshooting + +**Commands not found:** +```bash +# Make sure kanban-cli is in PATH +which kanban-cli +# Should output: /Users/username/.local/bin/kanban-cli +``` + +**No tickets found:** +```bash +# Check if tickets are linked to this branch +kanban-cli find-by-branch "$(git branch --show-current)" + +# Verify database exists +ls ~/Library/Application\ Support/com.ushadow.launcher/kanban.db +``` + +**Status not updating:** +```bash +# Run with full path +~/.local/bin/kanban-cli move-to-progress "branch-name" + +# Check stderr for errors +kanban-cli move-to-progress "branch-name" 2>&1 +``` + +## See Also + +- [KANBAN_HOOKS.md](./KANBAN_HOOKS.md) - Technical reference +- [KANBAN_HOOKS_EXAMPLE.md](./KANBAN_HOOKS_EXAMPLE.md) - Complete walkthrough +- [README.md](./README.md) - Launcher overview diff --git a/ushadow/launcher/KANBAN_AUTO_STATUS.md b/ushadow/launcher/KANBAN_AUTO_STATUS.md new file mode 100644 index 00000000..9bdc7baf --- /dev/null +++ b/ushadow/launcher/KANBAN_AUTO_STATUS.md @@ -0,0 +1,422 @@ +# Automatic Kanban Status Updates - Complete Guide + +This document explains how ticket status automatically updates based on agent activity, combining multiple layers of automation. + +## Architecture Overview + +The system uses **three layers** of automatic status updates: + +1. **Launcher Integration** - Updates status when starting agents +2. **Claude Code Hooks** - Updates status based on session events +3. **Workmux Hooks** - Updates status when merging branches + +This approach is inspired by vibe-kanban's backend service integration but adapted to work with the ushadow launcher's architecture. + +## How It Works + +### Status Flow + +``` +User creates ticket + ↓ +Launcher starts agent for ticket → status: in_progress + ↓ +Agent working actively → status: in_progress + ↓ +Agent finishes, session ends → status: in_review + ↓ +User responds to agent → status: in_progress + ↓ +(repeat as needed) + ↓ +User merges branch → status: done +``` + +## Layer 1: Launcher Integration + +**File**: `src-tauri/src/commands/kanban.rs` + +When you start an agent for a ticket using the launcher, the `start_coding_agent_for_ticket` function automatically moves the ticket to `in_progress`. + +**Code** (lines 784-806): +```rust +// Automatically move ticket to in_progress when starting agent +eprintln!("[start_coding_agent_for_ticket] Moving ticket to in_progress..."); +if let Some(branch_name) = &ticket.branch_name { + let status_update = shell_command(&format!("kanban-cli move-to-progress \"{}\"", branch_name)) + .output(); + // ... error handling ... +} +``` + +**When it triggers**: Immediately when starting an agent for a ticket + +**What it does**: Moves ticket from `backlog` or `todo` to `in_progress` + +## Layer 2: Claude Code Hooks + +**Files**: `.claude/hooks/*.sh`, `.claude/settings.local.json` + +Claude Code hooks automatically update status based on session lifecycle events. + +### Hook Scripts + +1. **session-start.sh** - Runs when Claude Code starts + - Moves ticket to `in_progress` + - Indicates agent is ready to work + +2. **user-prompt-submit.sh** - Runs when user submits a prompt + - Moves ticket to `in_progress` + - Indicates agent resuming work after user responds + +3. **idle-notification.sh** - Runs when Claude Code becomes idle (waiting for input) + - Moves ticket to `in_review` + - Indicates agent finished responding and is waiting for user + - **This is the key hook** - fires after each agent response! + +4. **session-end.sh** - Runs when Claude Code exits + - Moves ticket to `in_review` + - Indicates session ended, waiting for human review + +### Configuration + +Hooks are configured in `.claude/settings.local.json`: + +```json +{ + "hooks": { + "SessionStart": [{ + "hooks": [{ + "type": "command", + "command": ".claude/hooks/session-start.sh", + "async": true + }] + }], + "UserPromptSubmit": [{ + "hooks": [{ + "type": "command", + "command": ".claude/hooks/user-prompt-submit.sh", + "async": true + }] + }], + "Notification": [{ + "matcher": "idle_prompt", + "hooks": [{ + "type": "command", + "command": ".claude/hooks/idle-notification.sh", + "async": true + }] + }], + "SessionEnd": [{ + "hooks": [{ + "type": "command", + "command": ".claude/hooks/session-end.sh", + "async": true + }] + }] + } +} +``` + +**Key features**: +- Hooks run asynchronously (don't block agent) +- Automatically detect current branch +- Silently skip if not in a git repo +- Require kanban-cli to be in PATH +- **`idle_prompt` notification** detects when agent finishes each response (not just session end!) + - This gives per-turn status updates within a long-running session + - Similar to vibe-kanban's approach for ACP-based agents + +## Layer 3: Workmux Integration + +**File**: `~/.config/workmux/config.yaml` + +When you merge a branch using `workmux merge`, it automatically moves tickets to `done`. + +**Configuration**: +```yaml +pre_merge: + - kanban-cli move-to-done "$WM_BRANCH_NAME" +``` + +**When it triggers**: Before `workmux merge` completes + +**What it does**: Moves all tickets for the branch to `done` + +## Installation & Setup + +### Prerequisites + +1. **kanban-cli must be installed**: + ```bash + cd ushadow/launcher/src-tauri + cargo build --release --bin kanban-cli + cp target/release/kanban-cli ~/.local/bin/ + ``` + +2. **Verify kanban-cli is in PATH**: + ```bash + which kanban-cli + # Should output: /Users/username/.local/bin/kanban-cli + ``` + +3. **Workmux hook configured** (should already be set): + ```bash + cat ~/.config/workmux/config.yaml + # Should contain: kanban-cli move-to-done "$WM_BRANCH_NAME" + ``` + +### Automatic Hook Setup + +The Claude Code hooks are already configured in this repository: + +✅ `.claude/hooks/session-start.sh` - Executable +✅ `.claude/hooks/user-prompt-submit.sh` - Executable +✅ `.claude/hooks/idle-notification.sh` - Executable (NEW!) +✅ `.claude/hooks/session-end.sh` - Executable +✅ `.claude/settings.local.json` - Hooks configured with idle_prompt matcher + +**No additional setup needed** - hooks will run automatically when you use Claude Code in this project. + +### Manual Setup (for other projects) + +To add automatic status updates to another project: + +1. Create `.claude/hooks/` directory +2. Copy hook scripts from this project +3. Make scripts executable: `chmod +x .claude/hooks/*.sh` +4. Add hooks configuration to `.claude/settings.local.json` + +## Testing + +### Test Layer 1: Launcher Integration + +```bash +# Create a test ticket in the launcher UI +# Start agent for the ticket +# Check status: +kanban-cli find-by-branch "ticket-branch-name" +# Should show: in_progress +``` + +### Test Layer 2: Claude Code Hooks + +```bash +# Start Claude Code in a ticket branch +claude + +# Check status during session: +kanban-cli find-by-branch "$(git branch --show-current)" +# Should show: in_progress + +# Exit Claude Code (Ctrl+C or Ctrl+D) +# Check status after exit: +kanban-cli find-by-branch "$(git branch --show-current)" +# Should show: in_review + +# Start Claude Code again +# Respond to a prompt +# Should move back to: in_progress +``` + +### Test Layer 3: Workmux Integration + +```bash +# After completing work on a ticket +workmux merge ticket-branch-name + +# Check status: +kanban-cli find-by-branch "ticket-branch-name" +# Should show: done +``` + +### Debug Hooks + +To see if hooks are running: + +```bash +# Check Claude Code debug logs +tail -f ~/.claude/debug/*.log | grep -i kanban + +# Check workmux hook execution +# Should see output when running: workmux merge +``` + +## Comparison with Vibe-Kanban + +### Vibe-Kanban Approach + +**Architecture**: Backend service with execution process management + +**Key insights from code analysis**: +- `crates/services/src/services/container.rs` + - `start_execution` (line 974-992): Updates to `InProgress` + - `spawn_exit_monitor` (line 342-540): Waits for process exit or exit signal + - `finalize_task` (line 166-213): Updates to `InReview` + +**How it detects completion**: +- **For ACP-based agents** (Gemini, Qwen): + - Uses Agent Client Protocol with awaitable `prompt()` method + - Sends exit signal when turn completes (container.rs:486-487) + - Status updates after each response + +- **For Claude Code**: + - Spawns **new process for each prompt**! (container.rs:363 - exit_signal is None) + - Process exits when response complete + - No long-running session + +### Ushadow Launcher Approach + +**Architecture**: Hook-based with CLI integration + idle detection + +**Key components**: +- Rust CLI tool (`kanban-cli`) +- Claude Code hooks (shell scripts) +- **`idle_prompt` notification** - detects when agent waits for input +- Launcher integration (Rust code) +- Workmux hooks (YAML config) + +**How it detects completion**: +- **Long-running Claude Code session** +- `Notification(idle_prompt)` hook fires when agent finishes responding +- Status updates after each response (just like vibe-kanban's ACP agents!) +- Single process for entire conversation + +**Advantages over vibe-kanban's Claude Code approach**: +- ✅ **Keeps conversation context** - single long-running session +- ✅ **Per-response status updates** - via idle_prompt notification +- ✅ Works with any CLI tool (not just Claude Code) +- ✅ No backend service required +- ✅ Easy to debug (just check CLI calls) +- ✅ No process spawn overhead per prompt + +**Key difference**: +- Vibe-kanban: Short-lived processes (one per prompt) +- Ushadow launcher: Long-lived session + idle detection hooks + +## Troubleshooting + +### Hooks Not Running + +**Check if kanban-cli is in PATH**: +```bash +which kanban-cli +# If not found, install it (see Installation section) +``` + +**Check hook permissions**: +```bash +ls -la .claude/hooks/ +# All .sh files should be executable (rwxr-xr-x) +chmod +x .claude/hooks/*.sh +``` + +**Check Claude Code hook configuration**: +```bash +cat .claude/settings.local.json +# Should contain hooks configuration +``` + +**Enable Claude Code debug logging**: +```bash +CLAUDE_DEBUG=1 claude +tail -f ~/.claude/debug/*.log +``` + +### Status Not Updating + +**Verify ticket is linked to branch**: +```bash +kanban-cli find-by-branch "$(git branch --show-current)" +# Should return ticket(s) +``` + +**Manually test CLI command**: +```bash +kanban-cli move-to-progress "$(git branch --show-current)" +# Should succeed and update status +``` + +**Check database**: +```bash +sqlite3 ~/Library/Application\ Support/com.ushadow.launcher/kanban.db \ + "SELECT id, title, status, branch_name FROM tickets WHERE branch_name = 'your-branch'" +``` + +### Wrong Status After Merge + +**Check workmux hook**: +```bash +cat ~/.config/workmux/config.yaml +# Should contain: kanban-cli move-to-done "$WM_BRANCH_NAME" +``` + +**Test hook manually**: +```bash +export WM_BRANCH_NAME="test-branch" +kanban-cli move-to-done "$WM_BRANCH_NAME" +``` + +## Future Enhancements + +### Potential Improvements + +1. **Mid-session detection**: Detect when agent is waiting for user input during a session + - Could use tmux pane monitoring + - Could integrate with Claude Code's message flow + - Would enable more granular status updates + +2. **Activity monitoring**: Detect if agent is actively working vs idle + - Monitor tmux pane activity + - Track time since last command + - Auto-move to in_review after inactivity + +3. **Web API**: Expose status updates via HTTP API + - Allow external tools to update status + - Enable integrations with other systems + - Support webhooks for status changes + +4. **Notifications**: Alert on status changes + - Slack/Discord notifications + - Desktop notifications + - Email alerts + +5. **Analytics**: Track ticket lifecycle metrics + - Time in each status + - Agent productivity metrics + - Bottleneck identification + +## See Also + +- [AGENT_SELF_REPORTING.md](./AGENT_SELF_REPORTING.md) - Agent-side status reporting +- [KANBAN_HOOKS.md](./KANBAN_HOOKS.md) - Technical hook reference +- [KANBAN_HOOKS_EXAMPLE.md](./KANBAN_HOOKS_EXAMPLE.md) - Complete walkthrough +- [README.md](./README.md) - Launcher overview + +## Implementation Notes + +### Why Three Layers? + +Each layer covers a different lifecycle event: + +1. **Launcher**: Knows when agent starts (one-time event) +2. **Claude Code Hooks**: Knows session boundaries (start/end/resume) +3. **Workmux**: Knows when work is merged (completion event) + +No single layer can cover all cases, so we use all three together. + +### Why Async Hooks? + +Hooks run with `"async": true` to avoid blocking: +- Agent can start immediately while status updates in background +- Prevents delays if database is slow +- Failures don't break agent workflow + +### Why CLI Tool? + +Using `kanban-cli` instead of direct database access: +- Consistent error handling +- Easier to debug (run manually) +- Portable (works from shell, scripts, hooks) +- Single source of truth for status logic +- Can be called from any environment diff --git a/ushadow/launcher/KANBAN_HOOKS.md b/ushadow/launcher/KANBAN_HOOKS.md new file mode 100644 index 00000000..36a46e5e --- /dev/null +++ b/ushadow/launcher/KANBAN_HOOKS.md @@ -0,0 +1,289 @@ +# Kanban Hooks Integration + +This document explains how to automatically update Kanban ticket status based on workmux/tmux events. + +## Overview + +The launcher includes a CLI tool (`kanban-cli`) that can be called from workmux hooks to automatically update ticket status. The most common use case is moving tickets to "In Review" when an agent stops working and the branch is ready for merge. + +## Installation + +### 1. Build the CLI Tool + +```bash +cd ushadow/launcher/src-tauri +cargo build --release --bin kanban-cli + +# The binary will be at: target/release/kanban-cli +``` + +### 2. Install the CLI Tool + +Copy the binary to a location in your PATH: + +```bash +# Option 1: System-wide installation +sudo cp target/release/kanban-cli /usr/local/bin/ + +# Option 2: User installation +mkdir -p ~/.local/bin +cp target/release/kanban-cli ~/.local/bin/ +# Make sure ~/.local/bin is in your PATH + +# Verify installation +kanban-cli --help +``` + +## CLI Usage + +The `kanban-cli` tool provides several commands: + +### Set Ticket Status + +```bash +kanban-cli set-status +``` + +Statuses: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `archived` + +Example: +```bash +kanban-cli set-status ticket-abc123 in_review +``` + +### Find Tickets + +```bash +# Find by worktree path +kanban-cli find-by-path /path/to/worktree + +# Find by branch name +kanban-cli find-by-branch feature-branch + +# Find by tmux window name +kanban-cli find-by-window ushadow-feature-branch +``` + +### Move to Review (Most Useful) + +The `move-to-review` command is designed for use in hooks. It accepts a flexible identifier and automatically finds matching tickets: + +```bash +kanban-cli move-to-review +``` + +The identifier can be: +- Worktree path +- Branch name +- Tmux window name + +It will: +- Find all tickets matching the identifier +- Skip tickets already in "in_review" or "done" status +- Move remaining tickets to "in_review" +- Exit cleanly even if no tickets are found (not all worktrees have tickets) + +## Workmux Hook Configuration + +### Option 1: Global Configuration (Recommended) + +Edit `~/.config/workmux/config.yaml`: + +```yaml +# Commands to run before merging +pre_merge: + # Move associated tickets to "in_review" status + - kanban-cli move-to-review "$WM_BRANCH_NAME" + + # Optional: Run tests before merge + - pytest + - cargo test +``` + +This applies to **all** workmux projects. The hook will: +1. Find tickets associated with the branch being merged +2. Move them to "in_review" status +3. Continue with the merge process + +### Option 2: Project-Specific Configuration + +Edit `.workmux.yaml` in your project root: + +```yaml +pre_merge: + - "" # Inherit global hooks + - kanban-cli move-to-review "$WM_WORKTREE_PATH" + # Or use branch name: + # - kanban-cli move-to-review "$WM_BRANCH_NAME" +``` + +### Available Environment Variables in Hooks + +Workmux provides these variables in hook scripts: + +- `$WM_BRANCH_NAME`: The branch being merged (e.g., "feature-login") +- `$WM_TARGET_BRANCH`: The target branch (e.g., "main") +- `$WM_WORKTREE_PATH`: Absolute path to the worktree +- `$WM_PROJECT_ROOT`: Absolute path to the main project +- `$WM_HANDLE`: The worktree handle/window name + +## Other Hook Opportunities + +### Post-Create Hook + +Move tickets to "in_progress" when a worktree is created: + +```yaml +post_create: + - kanban-cli move-to-review "$WM_WORKTREE_PATH" + # Note: You'd need to add a "move-to-progress" command for this +``` + +### Pre-Remove Hook + +Archive tickets when a worktree is removed: + +```yaml +pre_remove: + - kanban-cli set-status archived +``` + +## Workflow Example + +Here's a typical workflow with automatic status updates: + +1. **Create Ticket in Kanban Board** + - Status: Backlog + - Create worktree for the ticket (links ticket to branch) + +2. **Start Working** + - Manually move ticket to "In Progress" in UI + - Or add a post-create hook to do this automatically + +3. **Finish Work** + - Run `workmux merge` to merge the branch + - **pre_merge hook automatically moves ticket to "In Review"** + - Merge completes + +4. **Review & Complete** + - Reviewer checks the code + - Manually move ticket to "Done" after approval + +## Troubleshooting + +### CLI Not Found + +```bash +# Check if it's in your PATH +which kanban-cli + +# If not, add ~/.local/bin to PATH in ~/.zshrc or ~/.bashrc: +export PATH="$HOME/.local/bin:$PATH" +``` + +### No Tickets Found + +This is normal! Not all worktrees have associated Kanban tickets. The `move-to-review` command exits successfully even when no tickets are found. + +To debug, manually check for tickets: + +```bash +# See what workmux sees +echo "Branch: $WM_BRANCH_NAME" +echo "Path: $WM_WORKTREE_PATH" + +# Check for tickets +kanban-cli find-by-branch "$WM_BRANCH_NAME" +``` + +### Database Not Found + +The CLI looks for the Kanban database at: +- macOS: `~/Library/Application Support/com.ushadow.launcher/kanban.db` +- Linux: `~/.local/share/com.ushadow.launcher/kanban.db` +- Windows: `%APPDATA%\com.ushadow.launcher\kanban.db` + +If the database doesn't exist, you need to: +1. Run the Ushadow Launcher at least once +2. Create at least one ticket (this initializes the database) + +### Hook Not Running + +Verify your workmux configuration: + +```bash +# Check global config +cat ~/.config/workmux/config.yaml + +# Check project config +cat .workmux.yaml + +# Test the command manually +kanban-cli move-to-review "your-branch-name" +``` + +## Advanced: Custom Status Transitions + +You can create custom scripts for different status transitions: + +### Script: `move-to-progress.sh` + +```bash +#!/bin/bash +kanban-cli set-status "$1" in_progress +``` + +### Script: `complete-ticket.sh` + +```bash +#!/bin/bash +# Move to done and close tmux window +kanban-cli set-status "$1" done +workmux close "$WM_HANDLE" +``` + +Make them executable and add to your PATH: + +```bash +chmod +x move-to-progress.sh complete-ticket.sh +mv *.sh ~/.local/bin/ +``` + +## Integration with Other Tools + +### Git Hooks + +You can also use `kanban-cli` in git hooks: + +```bash +# .git/hooks/pre-push +#!/bin/bash +BRANCH=$(git rev-parse --abbrev-ref HEAD) +kanban-cli move-to-review "$BRANCH" +``` + +### CI/CD + +Update ticket status from CI pipelines: + +```bash +# In your CI script +kanban-cli set-status "$TICKET_ID" done +``` + +## Future Enhancements + +Potential improvements to this system: + +- [ ] Auto-detect ticket ID from branch name (e.g., `ticket-123-feature`) +- [ ] Support for custom status workflows +- [ ] Slack/Discord notifications on status change +- [ ] Integration with GitHub/GitLab issues +- [ ] Web API for external integrations +- [ ] Rollback command for accidental status changes + +## See Also + +- [Workmux Documentation](https://github.com/joshka/workmux) +- [Launcher README](./README.md) +- [Kanban Board Usage](./README.md#managing-work-with-kanban-board) diff --git a/ushadow/launcher/Makefile b/ushadow/launcher/Makefile index 78bdd2ad..495720e7 100644 --- a/ushadow/launcher/Makefile +++ b/ushadow/launcher/Makefile @@ -43,8 +43,12 @@ release-test: -f release_name="Test Build - $$PLATFORM" && \ echo "✓ Workflow dispatched! Check: https://github.com/$$(git remote get-url origin | sed 's/.*://;s/.git//')/actions" +# Install dependencies if needed +node_modules: package.json package-lock.json + npm install + # Development -dev: +dev: node_modules npm run tauri:dev # Build frontend only (for quick iterations) diff --git a/ushadow/launcher/README.md b/ushadow/launcher/README.md index 407c618d..6b87917b 100644 --- a/ushadow/launcher/README.md +++ b/ushadow/launcher/README.md @@ -1,6 +1,17 @@ # Ushadow Desktop Launcher -A Tauri-based desktop application for orchestrating parallel development environments with git worktrees, tmux sessions, and Docker containers. +A Tauri-based desktop application for orchestrating parallel development environments with git worktrees, tmux sessions, and Docker containers. Includes integrated Kanban board for ticket management, making it a complete development workflow tool that bridges task tracking and environment management. + +## What Can It Do? + +- 🚀 **One-Click Launch** - Install prerequisites and start Ushadow automatically +- 🌲 **Git Worktrees** - Work on multiple branches simultaneously in isolated environments +- 💻 **Tmux Integration** - Persistent terminal sessions that survive app restarts +- 🐳 **Docker Orchestration** - Start/stop containers per environment with visual status +- 📋 **Kanban Board** - Integrated ticket management with epics and environment linking +- ⚙️ **Smart Setup** - Auto-configure credentials for new worktrees +- 🔄 **One-Click Merge** - Rebase and merge worktrees back to main with cleanup +- 📊 **Multi-Project** - Manage multiple repositories with independent configurations ## Features @@ -10,6 +21,7 @@ A Tauri-based desktop application for orchestrating parallel development environ - **Container Orchestration**: Start/stop Docker containers per environment - **Environment Discovery**: Auto-detect and manage multiple environments - **Fast Status Checks**: Cached Tailscale/Docker polling for instant feedback +- **Kanban Board**: Integrated ticket management system for tracking work and epics ### Developer Experience - **One-Click Terminal Access**: Open Terminal.app directly into environment's tmux session @@ -17,14 +29,18 @@ A Tauri-based desktop application for orchestrating parallel development environ - **Real-time Status Badges**: Visual indicators for tmux activity (Working/Waiting/Done/Error) - **Quick Environment Switching**: Manage multiple parallel tasks/features simultaneously - **Merge & Cleanup**: Rebase and merge worktrees back to main with one click +- **Ticket Management**: Create, track, and organize tickets with epics, descriptions, and environments ### Infrastructure - **Prerequisite Checking**: Verifies Docker, Tailscale, Git, and Tmux - **System Tray**: Runs in background with quick access menu - **Cross-Platform**: Builds for macOS (DMG), Windows (EXE), and Linux (DEB/AppImage) +- **Default Credentials**: Configure default admin credentials for new worktrees ## Quick Start +### For Developers (Running from Source) + ```bash # Install dependencies npm install @@ -38,15 +54,35 @@ npm run tauri:dev # 3. Show all environments with real-time status ``` +### For Users (Installing the App) + +1. Download the appropriate installer for your platform: + - **macOS**: `Ushadow-{version}.dmg` + - **Windows**: `Ushadow-{version}.exe` or `.msi` + - **Linux**: `ushadow_{version}_amd64.deb` or `.AppImage` + +2. Run the installer and launch the Ushadow Launcher + +3. Follow the first-time setup wizard + ### First-Time Usage 1. **Set Project Root**: Click the folder icon to point to your Ushadow repo 2. **Check Prerequisites**: Verify Docker, Tailscale, Git, Tmux are installed 3. **Start Infrastructure**: Start required containers (postgres, redis, etc.) 4. **Create Environment**: Click "New Environment" and choose: - - **Clone** - Create new git clone (traditional) + - **Link** - Link to an existing directory - **Worktree** - Create git worktree (recommended for parallel dev) +### Multi-Project Mode + +The launcher supports managing multiple projects with independent configurations: + +- Switch between projects from the Install tab +- Each project maintains its own worktrees directory +- Independent infrastructure and environment settings per project +- Useful for working on multiple repositories or client projects + ### Using Tmux Sessions - **Purple Terminal Icon** on environment cards - Click to open Terminal and attach to tmux @@ -55,6 +91,63 @@ npm run tauri:dev **Note**: Terminal opening currently works on **macOS only** (via Terminal.app). Linux/Windows support is planned. See [CROSS_PLATFORM_TERMINAL.md](./CROSS_PLATFORM_TERMINAL.md) for details. +### Configuring Default Credentials + +The **Credentials** button in the header allows you to configure default admin credentials that will be automatically written to new worktrees: + +1. Click **Credentials** button in the header +2. Enter default admin email, password, and name +3. Save settings +4. All newly created worktrees will automatically have these credentials configured in `secrets.yaml` + +This eliminates the need to manually configure credentials for each new environment, streamlining the development workflow. + +### Managing Work with Kanban Board + +The launcher includes an integrated Kanban board for ticket and epic management: + +- **Kanban Tab** in the header - View all tickets organized by status (Backlog, To Do, In Progress, Done) +- **Create Tickets** - Add new tickets with title, description, and link them to epics +- **Create Epics** - Organize related tickets into epics for better project structure +- **Environment Linking** - Associate tickets with specific development environments +- **Drag & Drop** - Move tickets between columns to update their status +- **Ticket Details** - View full ticket information including descriptions and metadata + +The Kanban board integrates with your backend API, allowing you to track work directly from the launcher while managing development environments. + +### Automatic Ticket Status Updates + +The launcher automatically updates ticket status as you work, using a three-layer system: + +**1. Launcher Integration**: When starting an agent → status: `in_progress` +**2. Claude Code Hooks**: Session lifecycle events → status: `in_progress` / `in_review` +**3. Workmux Integration**: When merging → status: `done` + +**Quick Setup:** + +```bash +# Install kanban-cli (required) +cd src-tauri +cargo build --release --bin kanban-cli +cp target/release/kanban-cli ~/.local/bin/ +``` + +**That's it!** Claude Code hooks are pre-configured in `.claude/` and workmux hooks are already set up. + +**How it works:** +- ✅ Start agent for ticket → Automatically moves to `in_progress` +- ✅ Agent finishes/waits → Automatically moves to `in_review` +- ✅ You respond → Automatically moves back to `in_progress` +- ✅ Merge branch → Automatically moves to `done` + +See **[KANBAN_AUTO_STATUS.md](./KANBAN_AUTO_STATUS.md)** for complete documentation: +- Architecture overview and how each layer works +- Comparison with vibe-kanban's approach +- Testing and troubleshooting +- Future enhancements + +For manual CLI usage and advanced integration, see **[KANBAN_HOOKS.md](./KANBAN_HOOKS.md)**. + ## Documentation - **[TMUX_INTEGRATION.md](./TMUX_INTEGRATION.md)** - Complete guide to tmux integration features (Phase 1) @@ -170,23 +263,52 @@ This generates all required icon sizes for each platform. launcher/ ├── dist/ # Bootstrap UI (shown before containers start) │ └── index.html +├── src/ # React frontend +│ ├── components/ # UI components +│ │ ├── KanbanBoard.tsx +│ │ ├── EnvironmentsPanel.tsx +│ │ ├── TmuxManagerDialog.tsx +│ │ └── ... +│ ├── hooks/ # React hooks (useTauri, useTmuxMonitoring) +│ ├── store/ # Zustand state management +│ └── App.tsx # Main application ├── src-tauri/ │ ├── Cargo.toml # Rust dependencies │ ├── tauri.conf.json # Tauri configuration │ ├── icons/ # App icons │ └── src/ -│ └── main.rs # Rust backend (Docker management) +│ ├── main.rs # Rust backend entry point +│ ├── commands/ # Tauri command implementations +│ │ ├── kanban.rs # Kanban board operations +│ │ ├── settings.rs # Settings management +│ │ └── ... +│ └── models.rs # Data structures └── package.json # Node scripts for Tauri CLI ``` ## How It Works -1. **On Launch**: Shows bootstrap UI with prerequisite checks -2. **Start Services**: Runs `docker compose up` for infrastructure and app -3. **Health Check**: Polls backend until healthy -4. **Open App**: Navigates webview to `http://localhost:3000` -5. **System Tray**: Minimizes to tray, stays running in background -6. **On Quit**: Optionally stops containers (configurable) +### Application Lifecycle + +1. **On Launch**: Shows Install page with one-click quick launch +2. **Prerequisite Check**: Verifies Docker, Git, Python, Tmux are installed +3. **Infrastructure Setup**: Starts shared services (Postgres, Redis, etc.) +4. **Environment Discovery**: Auto-detects existing worktrees and containers +5. **Tmux Integration**: Auto-starts tmux server and monitors sessions +6. **System Tray**: Minimizes to tray, stays running in background +7. **On Quit**: Optionally stops containers (configurable) + +### Development Workflow + +1. **Navigate to Kanban tab** - View and manage tickets +2. **Create a ticket** - Define the work to be done +3. **Navigate to Environments tab** - View all development environments +4. **Create worktree** - Click "New Environment" → Choose branch name +5. **Auto-setup** - Launcher creates worktree, tmux window, and starts containers +6. **Open terminal** - Click purple terminal icon to attach tmux session +7. **Develop** - Code in VS Code, run commands in tmux +8. **Track progress** - Update ticket status in Kanban board +9. **Merge & Cleanup** - When done, merge worktree back to main with one click ## Configuration @@ -204,17 +326,97 @@ The app uses Tauri's security features: - **CSP**: Restricts content sources to localhost - **Shell Scope**: Only allows specific Docker/Tailscale commands - **No Node.js**: Runs native Rust, not Node (unlike Electron) +- **Credential Storage**: Settings stored locally, never transmitted +- **Tauri Permissions**: Minimal permission model (no file system access beyond project paths) + +## Tips & Tricks + +### Productivity Tips + +**Use Worktrees for Parallel Development** +- Create a worktree for each feature/bug you're working on +- Switch between worktrees instantly without git stash +- Each worktree has its own containers and ports + +**Organize with Epics** +- Group related tickets into epics in the Kanban board +- Track progress across multiple related features +- Link tickets to environments for better context + +**Leverage Tmux Sessions** +- Keep long-running commands in tmux (tests, servers, logs) +- Sessions persist even if you close the launcher +- Use tmux windows to organize different tasks + +**Set Default Credentials** +- Configure default admin credentials once +- All new worktrees automatically get these credentials +- No more manual secrets.yaml editing + +### Keyboard Shortcuts + +- **Cmd/Ctrl + R**: Refresh all status +- **Native clipboard shortcuts work**: Cmd/Ctrl+C, V, X, Z, etc. + +### Best Practices + +1. **Name worktrees clearly**: Use descriptive names like `fix-login-bug` or `add-auth-feature` +2. **Clean up regularly**: Merge and delete completed worktrees to save disk space +3. **Use the Log Panel**: Expand it when troubleshooting to see detailed output +4. **Keep main up-to-date**: Regularly pull latest changes to avoid merge conflicts +5. **Link tickets to environments**: Use the Kanban board to track which ticket is in which environment ## Troubleshooting -### "Docker not found" -Ensure Docker Desktop is installed and the `docker` CLI is in your PATH. +### Environment Issues + +**"Docker not found"** +- Ensure Docker Desktop is installed and the `docker` CLI is in your PATH +- On macOS: `which docker` should show `/usr/local/bin/docker` +- Try restarting the launcher after installing Docker + +**"Tailscale not found"** +- Install Tailscale from https://tailscale.com/download +- Tailscale is optional but recommended for remote access + +**Environment won't start** +- Check that Docker is running (`docker ps` should work) +- Verify ports aren't already in use (default: 8000, 3000) +- Check logs in the Log Panel at the bottom of the launcher +- Try stopping and restarting the environment + +**Tmux window not created** +- Ensure tmux is installed: `tmux -V` +- Check if tmux server is running: `tmux list-sessions` +- Restart the launcher to auto-start tmux server + +### Kanban Board Issues + +**Tickets not loading** +- Ensure at least one environment is running (backend API needed) +- Check backend URL in Kanban tab matches running environment +- Verify backend is healthy at `http://localhost:8000/health` + +**Can't create tickets** +- Verify you have a running backend environment +- Check browser console for API errors +- Ensure credentials are configured (Settings button) + +### Build Issues + +**Build fails on Linux** +- Install all webkit/gtk dependencies listed in Prerequisites section +- Run: `sudo apt install libwebkit2gtk-4.0-dev build-essential` -### "Tailscale not found" -Install Tailscale from https://tailscale.com/download +**Windows build fails** +- Ensure WebView2 runtime is installed +- Install Visual Studio Build Tools +- Restart terminal after installing dependencies -### Build fails on Linux -Install all webkit/gtk dependencies listed in Prerequisites. +### General Tips -### Windows build fails -Ensure WebView2 runtime is installed and Visual Studio Build Tools are set up. +- **Check the Log Panel** - Most errors appear in the bottom log panel +- **Refresh Status** - Click the refresh button to update environment status +- **Restart the Launcher** - Many issues resolve after a fresh start +- **Check Disk Space** - Worktrees and containers can use significant space +- **Review Configuration** - Verify project root and worktrees directory paths diff --git a/ushadow/launcher/claude-with-kanban b/ushadow/launcher/claude-with-kanban new file mode 100755 index 00000000..badbbe52 --- /dev/null +++ b/ushadow/launcher/claude-with-kanban @@ -0,0 +1,43 @@ +#!/bin/bash +# Wrapper for Claude Code that automatically updates Kanban status +# +# Usage: claude-with-kanban [claude args...] +# +# What it does: +# 1. Moves tickets to "in_progress" when agent starts +# 2. Runs claude with all your arguments +# 3. Moves tickets to "in_review" when agent finishes +# +# Install: +# chmod +x claude-with-kanban +# cp claude-with-kanban ~/.local/bin/ +# alias claude='claude-with-kanban' + +# Get the current branch +BRANCH=$(git branch --show-current 2>/dev/null) + +if [ -z "$BRANCH" ]; then + echo "⚠ Not in a git repository, skipping kanban updates" + # Run claude without status updates + exec claude "$@" +fi + +echo "🤖 Starting agent for branch: $BRANCH" +echo " Moving tickets to 'in_progress'..." + +# Move to in_progress +kanban-cli move-to-progress "$BRANCH" 2>/dev/null + +# Run the actual claude command +# Use exec to replace this process +claude "$@" +EXIT_CODE=$? + +# After claude finishes +echo "" +echo "💬 Agent finished, moving tickets to 'in_review'..." +kanban-cli move-to-review "$BRANCH" 2>/dev/null + +echo "✓ Tickets updated and ready for review" + +exit $EXIT_CODE diff --git a/ushadow/launcher/clean-rebuild.sh b/ushadow/launcher/clean-rebuild.sh index d82f61d4..9d4f33a6 100755 --- a/ushadow/launcher/clean-rebuild.sh +++ b/ushadow/launcher/clean-rebuild.sh @@ -7,6 +7,13 @@ set -e # Exit on error cd "$(dirname "$0")" +# Ensure bundled resources exist +if [ ! -d "src-tauri/bundled" ]; then + echo "📦 Bundled resources not found, running bundle-resources.sh..." + bash bundle-resources.sh + echo "" +fi + echo "🧹 Cleaning caches..." # Clear Rust build cache diff --git a/ushadow/launcher/dist/index.html b/ushadow/launcher/dist/index.html index 32c42a44..edf2477f 100644 --- a/ushadow/launcher/dist/index.html +++ b/ushadow/launcher/dist/index.html @@ -5,8 +5,8 @@ Ushadow Launcher - - + +
diff --git a/ushadow/launcher/install-kanban-hooks.sh b/ushadow/launcher/install-kanban-hooks.sh new file mode 100755 index 00000000..f1ca9a98 --- /dev/null +++ b/ushadow/launcher/install-kanban-hooks.sh @@ -0,0 +1,147 @@ +#!/bin/bash + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${BLUE} Ushadow Launcher - Kanban Hooks Setup${NC}" +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo + +# Check if we're in the right directory +if [ ! -f "src-tauri/Cargo.toml" ]; then + echo -e "${RED}Error: Must run from launcher directory (ushadow/launcher)${NC}" + exit 1 +fi + +# Step 1: Build the CLI tool +echo -e "${YELLOW}Step 1: Building kanban-cli...${NC}" +cd src-tauri +if cargo build --release --bin kanban-cli; then + echo -e "${GREEN}✓ Built successfully${NC}" +else + echo -e "${RED}✗ Build failed${NC}" + exit 1 +fi +cd .. +echo + +# Step 2: Install the CLI tool +echo -e "${YELLOW}Step 2: Installing kanban-cli...${NC}" +INSTALL_DIR="$HOME/.local/bin" +CLI_SOURCE="src-tauri/target/release/kanban-cli" + +# Create installation directory if it doesn't exist +mkdir -p "$INSTALL_DIR" + +# Copy the binary +cp "$CLI_SOURCE" "$INSTALL_DIR/" +chmod +x "$INSTALL_DIR/kanban-cli" + +echo -e "${GREEN}✓ Installed to: $INSTALL_DIR/kanban-cli${NC}" + +# Check if directory is in PATH +if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then + echo -e "${YELLOW}⚠ $INSTALL_DIR is not in your PATH${NC}" + echo + echo "Add this to your ~/.zshrc or ~/.bashrc:" + echo -e "${BLUE}export PATH=\"\$HOME/.local/bin:\$PATH\"${NC}" + echo +fi +echo + +# Step 3: Verify installation +echo -e "${YELLOW}Step 3: Verifying installation...${NC}" +if command -v kanban-cli &> /dev/null; then + echo -e "${GREEN}✓ kanban-cli is available in PATH${NC}" + kanban-cli --help | head -5 +else + echo -e "${RED}✗ kanban-cli not found in PATH${NC}" + echo "You may need to restart your shell or run:" + echo -e "${BLUE}export PATH=\"\$HOME/.local/bin:\$PATH\"${NC}" +fi +echo + +# Step 4: Configure workmux hooks +echo -e "${YELLOW}Step 4: Configuring workmux hooks...${NC}" + +WORKMUX_CONFIG="$HOME/.config/workmux/config.yaml" +WORKMUX_DIR="$HOME/.config/workmux" + +# Create workmux config directory if it doesn't exist +if [ ! -d "$WORKMUX_DIR" ]; then + echo "Creating workmux config directory..." + mkdir -p "$WORKMUX_DIR" +fi + +# Check if config exists +if [ -f "$WORKMUX_CONFIG" ]; then + echo -e "${YELLOW}⚠ Workmux config already exists${NC}" + echo "Location: $WORKMUX_CONFIG" + echo + + # Check if hook is already configured + if grep -q "kanban-cli move-to-review" "$WORKMUX_CONFIG" 2>/dev/null; then + echo -e "${GREEN}✓ Kanban hook already configured${NC}" + else + echo "To enable automatic status updates, add this to your pre_merge hooks:" + echo + echo -e "${BLUE}pre_merge:" + echo " - kanban-cli move-to-review \"\$WM_BRANCH_NAME\"${NC}" + echo + fi +else + echo "Creating workmux config with kanban hooks..." + cat > "$WORKMUX_CONFIG" << 'EOF' +# Workmux global configuration +# See: workmux init for all options + +#------------------------------------------------------------------------------- +# Hooks +#------------------------------------------------------------------------------- + +# Commands to run before merging (e.g., linting, tests). +# Aborts the merge if any command fails. +pre_merge: + # Automatically move tickets to "in_review" status + - kanban-cli move-to-review "$WM_BRANCH_NAME" + + # Uncomment to run tests before merge: + # - npm test + # - cargo test + # - pytest + +EOF + echo -e "${GREEN}✓ Created workmux config with kanban hooks${NC}" + echo "Location: $WORKMUX_CONFIG" +fi +echo + +# Summary +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${GREEN}Setup Complete!${NC}" +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo +echo "What's next?" +echo +echo "1. ${GREEN}Test the CLI:${NC}" +echo " kanban-cli --help" +echo +echo "2. ${GREEN}Create a ticket in the Kanban board${NC}" +echo " - Link it to a worktree/branch" +echo +echo "3. ${GREEN}Test the hook:${NC}" +echo " - Make changes in the worktree" +echo " - Run: workmux merge" +echo " - The ticket should automatically move to 'In Review'" +echo +echo "4. ${GREEN}View documentation:${NC}" +echo " cat KANBAN_HOOKS.md" +echo +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" diff --git a/ushadow/launcher/kanban-status-helpers.sh b/ushadow/launcher/kanban-status-helpers.sh new file mode 100644 index 00000000..c608fb74 --- /dev/null +++ b/ushadow/launcher/kanban-status-helpers.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# Kanban Status Helper Functions +# Source this file in your shell: source kanban-status-helpers.sh + +# Get the current ticket ID from branch name or environment +get_current_ticket_id() { + # Check if TICKET_ID is set + if [ -n "$TICKET_ID" ]; then + echo "$TICKET_ID" + return 0 + fi + + # Try to get from branch name + local branch=$(git branch --show-current 2>/dev/null) + if [ -n "$branch" ]; then + echo "$branch" + return 0 + fi + + # Fallback to worktree path + echo "$(pwd)" +} + +# Agent starts working (after receiving human input) +kanban-start-working() { + local identifier=$(get_current_ticket_id) + echo "📝 Moving ticket to 'in_progress'..." + kanban-cli find-by-branch "$identifier" | grep -o 'ticket-[a-f0-9-]*' | while read ticket_id; do + kanban-cli set-status "$ticket_id" in_progress + done +} + +# Agent stops and waits for human (needs input) +kanban-waiting-for-human() { + local identifier=$(get_current_ticket_id) + echo "💬 Moving ticket to 'in_review' (waiting for human)..." + kanban-cli find-by-branch "$identifier" | grep -o 'ticket-[a-f0-9-]*' | while read ticket_id; do + kanban-cli set-status "$ticket_id" in_review + done +} + +# Mark ticket as done (usually via workmux merge hook) +kanban-mark-done() { + local identifier=$(get_current_ticket_id) + echo "✅ Moving ticket to 'done'..." + kanban-cli find-by-branch "$identifier" | grep -o 'ticket-[a-f0-9-]*' | while read ticket_id; do + kanban-cli set-status "$ticket_id" done + done +} + +# Quick status check +kanban-status() { + local identifier=$(get_current_ticket_id) + echo "Current identifier: $identifier" + echo "" + kanban-cli find-by-branch "$identifier" +} + +# Aliases for convenience +alias kb-start='kanban-start-working' +alias kb-waiting='kanban-waiting-for-human' +alias kb-done='kanban-mark-done' +alias kb-status='kanban-status' + +echo "✓ Kanban status helpers loaded" +echo "" +echo "Commands available:" +echo " kanban-start-working (or: kb-start) - Move to 'in_progress'" +echo " kanban-waiting-for-human (or: kb-waiting) - Move to 'in_review'" +echo " kanban-mark-done (or: kb-done) - Move to 'done'" +echo " kanban-status (or: kb-status) - Check current status" diff --git a/ushadow/launcher/package-lock.json b/ushadow/launcher/package-lock.json index 18033281..57d02608 100644 --- a/ushadow/launcher/package-lock.json +++ b/ushadow/launcher/package-lock.json @@ -1,17 +1,20 @@ { "name": "ushadow-launcher", - "version": "0.3.0", + "version": "0.8.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ushadow-launcher", - "version": "0.3.0", + "version": "0.8.22", "dependencies": { "@tauri-apps/api": "^1.5.3", "lucide-react": "^0.446.0", + "nanoid": "^5.1.6", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "zustand": "^5.0.9" }, "devDependencies": { @@ -72,6 +75,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -769,7 +773,6 @@ "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", @@ -785,7 +788,6 @@ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -797,7 +799,6 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -811,7 +812,6 @@ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/core": "^0.17.0" }, @@ -825,7 +825,6 @@ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -839,7 +838,6 @@ "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -864,7 +862,6 @@ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -876,7 +873,6 @@ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4" } @@ -887,7 +883,6 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -901,7 +896,6 @@ "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -915,7 +909,6 @@ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -926,7 +919,6 @@ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" @@ -941,7 +933,6 @@ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18.0" } @@ -952,7 +943,6 @@ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" @@ -967,7 +957,6 @@ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=12.22" }, @@ -982,7 +971,6 @@ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18" }, @@ -1698,34 +1686,73 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", "license": "MIT", - "peer": true + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1741,6 +1768,12 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.52.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.52.0.tgz", @@ -1776,6 +1809,7 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -1974,6 +2008,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2015,7 +2055,6 @@ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -2026,7 +2065,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2044,7 +2082,6 @@ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2088,8 +2125,7 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "Python-2.0", - "peer": true + "license": "Python-2.0" }, "node_modules/autoprefixer": { "version": "10.4.23", @@ -2128,6 +2164,16 @@ "postcss": "^8.1.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2201,6 +2247,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2221,7 +2268,6 @@ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -2257,13 +2303,22 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -2275,6 +2330,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2319,7 +2414,6 @@ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2332,8 +2426,17 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", "license": "MIT", - "peer": true + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } }, "node_modules/commander": { "version": "4.1.1", @@ -2350,8 +2453,7 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/convert-source-map": { "version": "2.0.0", @@ -2366,7 +2468,6 @@ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -2393,14 +2494,12 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2414,13 +2513,47 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", "license": "MIT", - "peer": true + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } }, "node_modules/didyoumean": { "version": "1.2.2", @@ -2498,7 +2631,6 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -2573,7 +2705,6 @@ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -2604,7 +2735,6 @@ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2616,7 +2746,6 @@ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2630,7 +2759,6 @@ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4" } @@ -2641,7 +2769,6 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2655,7 +2782,6 @@ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", @@ -2674,7 +2800,6 @@ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2688,7 +2813,6 @@ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -2702,7 +2826,6 @@ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -2716,29 +2839,42 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-glob": { "version": "3.3.3", @@ -2775,16 +2911,14 @@ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fastq": { "version": "1.20.1", @@ -2802,7 +2936,6 @@ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flat-cache": "^4.0.0" }, @@ -2829,7 +2962,6 @@ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -2847,7 +2979,6 @@ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -2861,8 +2992,7 @@ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/fraction.js": { "version": "5.3.4", @@ -2932,7 +3062,6 @@ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2946,7 +3075,6 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2964,6 +3092,56 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -2980,7 +3158,6 @@ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -2998,11 +3175,40 @@ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.19" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3032,6 +3238,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3055,6 +3271,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3065,13 +3291,24 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/jiti": { "version": "1.21.7", @@ -3079,6 +3316,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -3095,7 +3333,6 @@ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -3121,24 +3358,21 @@ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", @@ -3159,7 +3393,6 @@ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "json-buffer": "3.0.1" } @@ -3170,7 +3403,6 @@ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -3205,7 +3437,6 @@ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -3221,8 +3452,17 @@ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", "license": "MIT", - "peer": true + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } }, "node_modules/loose-envify": { "version": "1.4.0", @@ -3255,34 +3495,889 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", "license": "MIT", - "engines": { - "node": ">= 8" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" }, - "engines": { - "node": ">=8.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, "license": "ISC", "dependencies": { @@ -3299,7 +4394,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -3315,10 +4409,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", "funding": [ { "type": "github", @@ -3327,10 +4420,10 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/natural-compare": { @@ -3383,7 +4476,6 @@ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -3402,7 +4494,6 @@ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -3419,7 +4510,6 @@ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -3436,7 +4526,6 @@ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "callsites": "^3.0.0" }, @@ -3444,13 +4533,37 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3461,7 +4574,6 @@ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3533,6 +4645,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3676,24 +4789,51 @@ "dev": true, "license": "MIT" }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8.0" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -3724,6 +4864,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3744,6 +4885,33 @@ "react": "^18.3.1" } }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3777,6 +4945,72 @@ "node": ">=8.10.0" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -3804,7 +5038,6 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -3917,7 +5150,6 @@ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -3931,7 +5163,6 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3946,13 +5177,36 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" }, @@ -3960,6 +5214,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -3989,7 +5261,6 @@ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -4112,6 +5383,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4132,6 +5404,26 @@ "node": ">=8.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -4158,7 +5450,6 @@ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -4172,6 +5463,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4180,6 +5472,93 @@ "node": ">=14.17" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4217,7 +5596,6 @@ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "punycode": "^2.1.0" } @@ -4229,12 +5607,41 @@ "dev": true, "license": "MIT" }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -4295,7 +5702,6 @@ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -4312,7 +5718,6 @@ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4330,7 +5735,6 @@ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -4366,6 +5770,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/ushadow/launcher/package.json b/ushadow/launcher/package.json index 5e9a979b..909c3fa1 100644 --- a/ushadow/launcher/package.json +++ b/ushadow/launcher/package.json @@ -1,6 +1,6 @@ { "name": "ushadow-launcher", - "version": "0.7.15", + "version": "0.8.22", "description": "Ushadow Desktop Launcher", "private": true, "type": "module", @@ -23,8 +23,11 @@ "dependencies": { "@tauri-apps/api": "^1.5.3", "lucide-react": "^0.446.0", + "nanoid": "^5.1.6", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "zustand": "^5.0.9" }, "devDependencies": { diff --git a/ushadow/launcher/pnpm-lock.yaml b/ushadow/launcher/pnpm-lock.yaml index 57687802..5074b46f 100644 --- a/ushadow/launcher/pnpm-lock.yaml +++ b/ushadow/launcher/pnpm-lock.yaml @@ -11,27 +11,24 @@ importers: '@tauri-apps/api': specifier: ^1.5.3 version: 1.6.0 - '@xterm/addon-fit': - specifier: ^0.10.0 - version: 0.10.0(@xterm/xterm@5.5.0) - '@xterm/addon-web-links': - specifier: ^0.11.0 - version: 0.11.0(@xterm/xterm@5.5.0) - '@xterm/xterm': - specifier: ^5.5.0 - version: 5.5.0 lucide-react: specifier: ^0.446.0 version: 0.446.0(react@18.3.1) + nanoid: + specifier: ^5.1.6 + version: 5.1.6 react: specifier: ^18.3.1 version: 18.3.1 react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) - react-draggable: - specifier: ^4.4.6 - version: 4.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@18.3.27)(react@18.3.1) + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 zustand: specifier: ^5.0.9 version: 5.0.10(@types/react@18.3.27)(react@18.3.1) @@ -588,12 +585,27 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -605,6 +617,12 @@ packages: '@types/react@18.3.27': resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript-eslint/eslint-plugin@8.53.0': resolution: {integrity: sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -664,25 +682,15 @@ packages: resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@xterm/addon-fit@0.10.0': - resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} - peerDependencies: - '@xterm/xterm': ^5.0.0 - - '@xterm/addon-web-links@0.11.0': - resolution: {integrity: sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==} - peerDependencies: - '@xterm/xterm': ^5.0.0 - - '@xterm/xterm@5.5.0': - resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -720,6 +728,9 @@ packages: peerDependencies: postcss: ^8.1.0 + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -757,18 +768,29 @@ packages: caniuse-lite@1.0.30001764: resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -776,6 +798,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -807,9 +832,19 @@ packages: supports-color: optional: true + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -832,6 +867,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-scope@8.4.0: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -870,10 +909,16 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -953,6 +998,15 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -969,6 +1023,15 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -977,6 +1040,9 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -985,10 +1051,17 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1043,6 +1116,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -1055,10 +1131,142 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -1081,6 +1289,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -1115,6 +1328,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1196,8 +1412,8 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prop-types@15.8.1: - resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} @@ -1211,14 +1427,11 @@ packages: peerDependencies: react: ^18.3.1 - react-draggable@4.5.0: - resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==} + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: - react: '>= 16.3.0' - react-dom: '>= 16.3.0' - - react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + '@types/react': '>=18' + react: '>=18' react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} @@ -1235,6 +1448,18 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1280,10 +1505,22 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -1317,6 +1554,12 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -1335,6 +1578,24 @@ packages: engines: {node: '>=14.17'} hasBin: true + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -1347,6 +1608,12 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1412,6 +1679,9 @@ packages: use-sync-external-store: optional: true + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -1830,10 +2100,28 @@ snapshots: dependencies: '@babel/types': 7.28.6 + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + '@types/estree@1.0.8': {} + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.27)': @@ -1845,6 +2133,10 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + '@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -1936,6 +2228,8 @@ snapshots: '@typescript-eslint/types': 8.53.0 eslint-visitor-keys: 4.2.1 + '@ungap/structured-clone@1.3.0': {} + '@vitejs/plugin-react@4.7.0(vite@5.4.21)': dependencies: '@babel/core': 7.28.6 @@ -1948,16 +2242,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': - dependencies: - '@xterm/xterm': 5.5.0 - - '@xterm/addon-web-links@0.11.0(@xterm/xterm@5.5.0)': - dependencies: - '@xterm/xterm': 5.5.0 - - '@xterm/xterm@5.5.0': {} - acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -1995,6 +2279,8 @@ snapshots: postcss: 8.5.6 postcss-value-parser: 4.2.0 + bail@2.0.2: {} + balanced-match@1.0.2: {} baseline-browser-mapping@2.9.15: {} @@ -2028,11 +2314,21 @@ snapshots: caniuse-lite@1.0.30001764: {} + ccount@2.0.1: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -2045,14 +2341,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - clsx@2.1.1: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 color-name@1.1.4: {} + comma-separated-tokens@2.0.3: {} + commander@4.1.1: {} concat-map@0.0.1: {} @@ -2073,8 +2369,18 @@ snapshots: dependencies: ms: 2.1.3 + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + deep-is@0.1.4: {} + dequal@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + didyoumean@1.2.2: {} dlv@1.1.3: {} @@ -2111,6 +2417,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -2177,8 +2485,12 @@ snapshots: estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} + esutils@2.0.3: {} + extend@3.0.2: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -2246,6 +2558,32 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + html-url-attributes@3.0.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -2257,6 +2595,15 @@ snapshots: imurmurhash@0.1.4: {} + inline-style-parser@0.2.7: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -2265,14 +2612,20 @@ snapshots: dependencies: hasown: 2.0.2 + is-decimal@2.0.1: {} + is-extglob@2.1.1: {} is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-number@7.0.0: {} + is-plain-obj@4.1.0: {} + isexe@2.0.0: {} jiti@1.21.7: {} @@ -2312,6 +2665,8 @@ snapshots: lodash.merge@4.6.2: {} + longest-streak@3.1.0: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -2324,8 +2679,354 @@ snapshots: dependencies: react: 18.3.1 + markdown-table@3.0.4: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + merge2@1.4.1: {} + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -2349,6 +3050,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.6: {} + natural-compare@1.4.0: {} node-releases@2.0.27: {} @@ -2380,6 +3083,16 @@ snapshots: dependencies: callsites: 3.1.0 + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -2435,11 +3148,7 @@ snapshots: prelude-ls@1.2.1: {} - prop-types@15.8.1: - dependencies: - loose-envify: 1.4.0 - object-assign: 4.1.1 - react-is: 16.13.1 + property-information@7.1.0: {} punycode@2.3.1: {} @@ -2451,14 +3160,23 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-draggable@4.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-markdown@10.1.0(@types/react@18.3.27)(react@18.3.1): dependencies: - clsx: 2.1.1 - prop-types: 15.8.1 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 18.3.27 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react-is@16.13.1: {} + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color react-refresh@0.17.0: {} @@ -2474,6 +3192,40 @@ snapshots: dependencies: picomatch: 2.3.1 + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + resolve-from@4.0.0: {} resolve@1.22.11: @@ -2535,8 +3287,23 @@ snapshots: source-map-js@1.2.1: {} + space-separated-tokens@2.0.2: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-json-comments@3.1.1: {} + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -2598,6 +3365,10 @@ snapshots: dependencies: is-number: 7.0.0 + trim-lines@3.0.1: {} + + trough@2.2.0: {} + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -2610,6 +3381,39 @@ snapshots: typescript@5.9.3: {} + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -2622,6 +3426,16 @@ snapshots: util-deprecate@1.0.2: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite@5.4.21: dependencies: esbuild: 0.21.5 @@ -2644,3 +3458,5 @@ snapshots: optionalDependencies: '@types/react': 18.3.27 react: 18.3.1 + + zwitch@2.0.4: {} diff --git a/ushadow/launcher/public/oauth-callback.html b/ushadow/launcher/public/oauth-callback.html new file mode 100644 index 00000000..af04e9a9 --- /dev/null +++ b/ushadow/launcher/public/oauth-callback.html @@ -0,0 +1,165 @@ + + + + + Completing Login... + + + +
+
+
Completing authentication...
+
Please wait while we process your login
+
+ + + + diff --git a/ushadow/launcher/scripts/build.sh b/ushadow/launcher/scripts/build.sh index 5acfebd5..eab5c981 100755 --- a/ushadow/launcher/scripts/build.sh +++ b/ushadow/launcher/scripts/build.sh @@ -15,6 +15,14 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' +# Ensure bundled resources exist +if [ ! -d "$LAUNCHER_DIR/src-tauri/bundled" ]; then + echo -e "${YELLOW}📦 Bundled resources not found, running bundle-resources.sh...${NC}" + cd "$LAUNCHER_DIR" + bash bundle-resources.sh + echo "" +fi + show_usage() { echo "Usage: $0 [--debug]" echo "" diff --git a/ushadow/launcher/src-tauri/Cargo.lock b/ushadow/launcher/src-tauri/Cargo.lock index a1a8fb15..1e9f2988 100644 --- a/ushadow/launcher/src-tauri/Cargo.lock +++ b/ushadow/launcher/src-tauri/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -719,6 +731,12 @@ dependencies = [ "syn 2.0.113", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deranged" version = "0.5.5" @@ -953,6 +971,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1105,6 +1135,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1175,6 +1206,7 @@ dependencies = [ "futures-core", "futures-io", "futures-macro", + "futures-sink", "futures-task", "memchr", "pin-project-lite", @@ -1515,7 +1547,7 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", + "http 0.2.12", "indexmap 2.12.1", "slab", "tokio", @@ -1540,6 +1572,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1555,6 +1596,39 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "headers" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06683b93020a07e3dbcf5f8c0f6d40080d725bea7936fc01ad345c01b97dc270" +dependencies = [ + "base64 0.21.7", + "bytes", + "headers-core", + "http 0.2.12", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7f66481bfee273957b1f20485a4ff3362987f85b2c236580d81b4eb7a326429" +dependencies = [ + "http 0.2.12", +] + [[package]] name = "heck" version = "0.3.3" @@ -1613,6 +1687,16 @@ dependencies = [ "itoa 1.0.17", ] +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa 1.0.17", +] + [[package]] name = "http-body" version = "0.4.6" @@ -1620,7 +1704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http", + "http 0.2.12", "pin-project-lite", ] @@ -1653,7 +1737,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", + "http 0.2.12", "http-body", "httparse", "httpdate", @@ -2096,6 +2180,17 @@ dependencies = [ "redox_syscall 0.7.0", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2233,6 +2328,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2264,6 +2369,24 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multer" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01acbdc23469fd8fe07ab135923371d5f5a422fbf9c522158677c8eb15bc51c2" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 0.2.12", + "httparse", + "log", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -2850,6 +2973,26 @@ dependencies = [ "siphasher 1.0.1", ] +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.113", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -3266,7 +3409,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", + "http 0.2.12", "http-body", "hyper", "hyper-tls", @@ -3318,6 +3461,20 @@ dependencies = [ "windows 0.37.0", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.10.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -3662,6 +3819,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3824,6 +3992,12 @@ dependencies = [ "system-deps 5.0.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4058,7 +4232,7 @@ dependencies = [ "glob", "gtk", "heck 0.5.0", - "http", + "http 0.2.12", "ignore", "log", "nix 0.26.4", @@ -4161,7 +4335,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8066855882f00172935e3fa7d945126580c34dcbabab43f5d4f0c2398a67d47b" dependencies = [ "gtk", - "http", + "http 0.2.12", "http-range", "rand 0.8.5", "raw-window-handle", @@ -4428,6 +4602,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4558,6 +4744,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4630,6 +4817,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -4647,6 +4853,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -4679,13 +4891,14 @@ dependencies = [ [[package]] name = "ushadow-launcher" -version = "0.7.14" +version = "0.8.22" dependencies = [ "chrono", "dirs", "open 5.3.3", "portable-pty", "reqwest", + "rusqlite", "serde", "serde_json", "serde_yaml", @@ -4693,6 +4906,7 @@ dependencies = [ "tauri-build", "tokio", "uuid", + "warp", ] [[package]] @@ -4788,6 +5002,35 @@ dependencies = [ "try-lock", ] +[[package]] +name = "warp" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4378d202ff965b011c64817db11d5829506d3404edeadb61f190d111da3f231c" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "headers", + "http 0.2.12", + "hyper", + "log", + "mime", + "mime_guess", + "multer", + "percent-encoding", + "pin-project", + "scoped-tls", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tokio-tungstenite", + "tokio-util", + "tower-service", + "tracing", +] + [[package]] name = "wasi" version = "0.9.0+wasi-snapshot-preview1" @@ -5727,7 +5970,7 @@ dependencies = [ "glib", "gtk", "html5ever", - "http", + "http 0.2.12", "kuchikiki", "libc", "log", diff --git a/ushadow/launcher/src-tauri/Cargo.toml b/ushadow/launcher/src-tauri/Cargo.toml index 766cb06b..0afdb06e 100644 --- a/ushadow/launcher/src-tauri/Cargo.toml +++ b/ushadow/launcher/src-tauri/Cargo.toml @@ -1,27 +1,37 @@ [package] name = "ushadow-launcher" -version = "0.7.15" +version = "0.8.22" description = "Ushadow Desktop Launcher" authors = ["Ushadow"] license = "MIT" repository = "" edition = "2021" +[[bin]] +name = "ushadow-launcher" +path = "src/main.rs" + +[[bin]] +name = "kanban-cli" +path = "src/bin/kanban-cli.rs" + [build-dependencies] tauri-build = { version = "1", features = [] } [dependencies] -tauri = { version = "1", features = [ "clipboard-all", "path-all", "process-exit", "shell-execute", "process-relaunch", "shell-open", "process-command-api", "dialog-all", "notification-all", "system-tray"] } +tauri = { version = "1", features = [ "window-set-focus", "window-close", "window-set-size", "window-hide", "window-create", "window-show", "window-center", "window-set-position", "window-set-always-on-top", "window-set-title", "window-set-resizable", "clipboard-all", "path-all", "process-exit", "shell-execute", "process-relaunch", "shell-open", "process-command-api", "dialog-all", "notification-all", "system-tray"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" tokio = { version = "1", features = ["process", "time", "macros", "rt-multi-thread", "io-util", "sync"] } reqwest = { version = "0.11", features = ["blocking"] } +warp = "0.3" open = "5" portable-pty = "0.8" uuid = { version = "1.6", features = ["v4", "serde"] } dirs = "5" chrono = "0.4" +rusqlite = { version = "0.31", features = ["bundled"] } [features] default = ["custom-protocol"] diff --git a/ushadow/launcher/src-tauri/entitlements.plist b/ushadow/launcher/src-tauri/entitlements.plist new file mode 100644 index 00000000..a2f7a244 --- /dev/null +++ b/ushadow/launcher/src-tauri/entitlements.plist @@ -0,0 +1,14 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.disable-library-validation + + com.apple.security.network.client + + com.apple.security.network.server + + + diff --git a/ushadow/launcher/src-tauri/src/bin/kanban-cli.rs b/ushadow/launcher/src-tauri/src/bin/kanban-cli.rs new file mode 100644 index 00000000..9016f953 --- /dev/null +++ b/ushadow/launcher/src-tauri/src/bin/kanban-cli.rs @@ -0,0 +1,475 @@ +use rusqlite::{Connection, params}; +use std::path::PathBuf; +use std::env; + +#[derive(Debug)] +#[allow(dead_code)] +struct Ticket { + id: String, + title: String, + status: String, + worktree_path: Option, + branch_name: Option, + tmux_window_name: Option, +} + +/// Get the path to the SQLite database +fn get_db_path() -> Result { + let data_dir = dirs::data_dir().ok_or("Failed to get data directory")?; + let launcher_dir = data_dir.join("com.ushadow.launcher"); + + if !launcher_dir.exists() { + return Err(format!("Launcher data directory does not exist: {:?}", launcher_dir)); + } + + Ok(launcher_dir.join("kanban.db")) +} + +/// Get a database connection +fn get_db_connection() -> Result { + let db_path = get_db_path()?; + Connection::open(&db_path) + .map_err(|e| format!("Failed to open database: {}", e)) +} + +/// Find tickets by worktree path +fn find_tickets_by_worktree(worktree_path: &str) -> Result, String> { + let conn = get_db_connection()?; + + let mut stmt = conn.prepare( + "SELECT id, title, status, worktree_path, branch_name, tmux_window_name + FROM tickets + WHERE worktree_path = ?" + ).map_err(|e| format!("Failed to prepare statement: {}", e))?; + + let tickets = stmt.query_map([worktree_path], |row| { + Ok(Ticket { + id: row.get(0)?, + title: row.get(1)?, + status: row.get(2)?, + worktree_path: row.get(3)?, + branch_name: row.get(4)?, + tmux_window_name: row.get(5)?, + }) + }) + .map_err(|e| format!("Failed to query tickets: {}", e))? + .filter_map(|r| r.ok()) + .collect(); + + Ok(tickets) +} + +/// Find tickets by branch name +fn find_tickets_by_branch(branch_name: &str) -> Result, String> { + let conn = get_db_connection()?; + + let mut stmt = conn.prepare( + "SELECT id, title, status, worktree_path, branch_name, tmux_window_name + FROM tickets + WHERE branch_name = ?" + ).map_err(|e| format!("Failed to prepare statement: {}", e))?; + + let tickets = stmt.query_map([branch_name], |row| { + Ok(Ticket { + id: row.get(0)?, + title: row.get(1)?, + status: row.get(2)?, + worktree_path: row.get(3)?, + branch_name: row.get(4)?, + tmux_window_name: row.get(5)?, + }) + }) + .map_err(|e| format!("Failed to query tickets: {}", e))? + .filter_map(|r| r.ok()) + .collect(); + + Ok(tickets) +} + +/// Find tickets by tmux window name +fn find_tickets_by_tmux_window(window_name: &str) -> Result, String> { + let conn = get_db_connection()?; + + let mut stmt = conn.prepare( + "SELECT id, title, status, worktree_path, branch_name, tmux_window_name + FROM tickets + WHERE tmux_window_name = ?" + ).map_err(|e| format!("Failed to prepare statement: {}", e))?; + + let tickets = stmt.query_map([window_name], |row| { + Ok(Ticket { + id: row.get(0)?, + title: row.get(1)?, + status: row.get(2)?, + worktree_path: row.get(3)?, + branch_name: row.get(4)?, + tmux_window_name: row.get(5)?, + }) + }) + .map_err(|e| format!("Failed to query tickets: {}", e))? + .filter_map(|r| r.ok()) + .collect(); + + Ok(tickets) +} + +/// Update ticket status +fn update_ticket_status(ticket_id: &str, new_status: &str) -> Result<(), String> { + // Validate status + let valid_statuses = ["backlog", "todo", "in_progress", "in_review", "done", "archived"]; + if !valid_statuses.contains(&new_status) { + return Err(format!( + "Invalid status '{}'. Must be one of: {}", + new_status, + valid_statuses.join(", ") + )); + } + + let conn = get_db_connection()?; + let now = chrono::Utc::now().to_rfc3339(); + + let rows_affected = conn.execute( + "UPDATE tickets SET status = ?, updated_at = ? WHERE id = ?", + params![new_status, &now, ticket_id], + ).map_err(|e| format!("Failed to update ticket: {}", e))?; + + if rows_affected == 0 { + return Err(format!("Ticket not found: {}", ticket_id)); + } + + Ok(()) +} + +fn print_usage() { + eprintln!("Usage: kanban-cli [options]"); + eprintln!(); + eprintln!("Commands:"); + eprintln!(" set-status Update ticket status"); + eprintln!(" find-by-path Find tickets by worktree path"); + eprintln!(" find-by-branch Find tickets by branch name"); + eprintln!(" find-by-window Find tickets by tmux window name"); + eprintln!(" move-to-review Move ticket(s) to 'in_review' status"); + eprintln!(" move-to-progress Move ticket(s) to 'in_progress' status"); + eprintln!(" move-to-done Move ticket(s) to 'done' status"); + eprintln!(" (identifier can be path, branch, or window)"); + eprintln!(); + eprintln!("Statuses:"); + eprintln!(" backlog, todo, in_progress, in_review, done, archived"); + eprintln!(); + eprintln!("Examples:"); + eprintln!(" # Update specific ticket"); + eprintln!(" kanban-cli set-status ticket-123 in_review"); + eprintln!(); + eprintln!(" # Find tickets by worktree path"); + eprintln!(" kanban-cli find-by-path /path/to/worktree"); + eprintln!(); + eprintln!(" # Agent self-reporting workflow"); + eprintln!(" kanban-cli move-to-progress $BRANCH_NAME # Agent starts working"); + eprintln!(" kanban-cli move-to-review $BRANCH_NAME # Agent waits for human"); + eprintln!(" kanban-cli move-to-done $BRANCH_NAME # Work merged"); +} + +fn main() { + let args: Vec = env::args().collect(); + + if args.len() < 2 { + print_usage(); + std::process::exit(1); + } + + let command = &args[1]; + + let result = match command.as_str() { + "set-status" => { + if args.len() < 4 { + eprintln!("Error: set-status requires ticket ID and status"); + print_usage(); + std::process::exit(1); + } + let ticket_id = &args[2]; + let status = &args[3]; + + match update_ticket_status(ticket_id, status) { + Ok(_) => { + println!("✓ Updated ticket {} to status: {}", ticket_id, status); + Ok(()) + } + Err(e) => Err(e), + } + } + "find-by-path" => { + if args.len() < 3 { + eprintln!("Error: find-by-path requires worktree path"); + print_usage(); + std::process::exit(1); + } + let path = &args[2]; + + match find_tickets_by_worktree(path) { + Ok(tickets) => { + if tickets.is_empty() { + println!("No tickets found for path: {}", path); + } else { + println!("Found {} ticket(s):", tickets.len()); + for ticket in tickets { + println!(" {} - {} ({})", ticket.id, ticket.title, ticket.status); + } + } + Ok(()) + } + Err(e) => Err(e), + } + } + "find-by-branch" => { + if args.len() < 3 { + eprintln!("Error: find-by-branch requires branch name"); + print_usage(); + std::process::exit(1); + } + let branch = &args[2]; + + match find_tickets_by_branch(branch) { + Ok(tickets) => { + if tickets.is_empty() { + println!("No tickets found for branch: {}", branch); + } else { + println!("Found {} ticket(s):", tickets.len()); + for ticket in tickets { + println!(" {} - {} ({})", ticket.id, ticket.title, ticket.status); + } + } + Ok(()) + } + Err(e) => Err(e), + } + } + "find-by-window" => { + if args.len() < 3 { + eprintln!("Error: find-by-window requires tmux window name"); + print_usage(); + std::process::exit(1); + } + let window = &args[2]; + + match find_tickets_by_tmux_window(window) { + Ok(tickets) => { + if tickets.is_empty() { + println!("No tickets found for tmux window: {}", window); + } else { + println!("Found {} ticket(s):", tickets.len()); + for ticket in tickets { + println!(" {} - {} ({})", ticket.id, ticket.title, ticket.status); + } + } + Ok(()) + } + Err(e) => Err(e), + } + } + "move-to-review" => { + if args.len() < 3 { + eprintln!("Error: move-to-review requires identifier (path, branch, or window)"); + print_usage(); + std::process::exit(1); + } + let identifier = &args[2]; + + // Try to find tickets by different methods - try all methods until we find some tickets + let mut tickets = Vec::new(); + + // Try worktree path + if let Ok(found) = find_tickets_by_worktree(identifier) { + if !found.is_empty() { + tickets = found; + } + } + + // If no tickets found, try branch name + if tickets.is_empty() { + if let Ok(found) = find_tickets_by_branch(identifier) { + if !found.is_empty() { + tickets = found; + } + } + } + + // If still no tickets, try tmux window + if tickets.is_empty() { + if let Ok(found) = find_tickets_by_tmux_window(identifier) { + tickets = found; + } + } + + if tickets.is_empty() { + eprintln!("⚠ No tickets found for identifier: {}", identifier); + eprintln!(" This is OK - not all worktrees have associated tickets"); + Ok(()) + } else { + let mut errors = Vec::new(); + let mut updated = 0; + + for ticket in &tickets { + // Only update if not already in review or done + if ticket.status != "in_review" && ticket.status != "done" { + match update_ticket_status(&ticket.id, "in_review") { + Ok(_) => { + println!("✓ Moved ticket to review: {} - {}", ticket.id, ticket.title); + updated += 1; + } + Err(e) => { + errors.push(format!("Failed to update {}: {}", ticket.id, e)); + } + } + } else { + println!(" Skipped {} - already in status: {}", ticket.id, ticket.status); + } + } + + if !errors.is_empty() { + Err(errors.join("\n")) + } else { + if updated > 0 { + println!("✓ Moved {} ticket(s) to review", updated); + } + Ok(()) + } + } + } + "move-to-progress" => { + if args.len() < 3 { + eprintln!("Error: move-to-progress requires identifier (path, branch, or window)"); + print_usage(); + std::process::exit(1); + } + let identifier = &args[2]; + + // Try to find tickets by different methods + let mut tickets = Vec::new(); + + if let Ok(found) = find_tickets_by_worktree(identifier) { + if !found.is_empty() { + tickets = found; + } + } + + if tickets.is_empty() { + if let Ok(found) = find_tickets_by_branch(identifier) { + if !found.is_empty() { + tickets = found; + } + } + } + + if tickets.is_empty() { + if let Ok(found) = find_tickets_by_tmux_window(identifier) { + tickets = found; + } + } + + if tickets.is_empty() { + eprintln!("⚠ No tickets found for identifier: {}", identifier); + eprintln!(" This is OK - not all worktrees have associated tickets"); + Ok(()) + } else { + let mut errors = Vec::new(); + let mut updated = 0; + + for ticket in &tickets { + match update_ticket_status(&ticket.id, "in_progress") { + Ok(_) => { + println!("✓ Moved ticket to in_progress: {} - {}", ticket.id, ticket.title); + updated += 1; + } + Err(e) => { + errors.push(format!("Failed to update {}: {}", ticket.id, e)); + } + } + } + + if !errors.is_empty() { + Err(errors.join("\n")) + } else { + if updated > 0 { + println!("✓ Moved {} ticket(s) to in_progress", updated); + } + Ok(()) + } + } + } + "move-to-done" => { + if args.len() < 3 { + eprintln!("Error: move-to-done requires identifier (path, branch, or window)"); + print_usage(); + std::process::exit(1); + } + let identifier = &args[2]; + + // Try to find tickets by different methods + let mut tickets = Vec::new(); + + if let Ok(found) = find_tickets_by_worktree(identifier) { + if !found.is_empty() { + tickets = found; + } + } + + if tickets.is_empty() { + if let Ok(found) = find_tickets_by_branch(identifier) { + if !found.is_empty() { + tickets = found; + } + } + } + + if tickets.is_empty() { + if let Ok(found) = find_tickets_by_tmux_window(identifier) { + tickets = found; + } + } + + if tickets.is_empty() { + eprintln!("⚠ No tickets found for identifier: {}", identifier); + eprintln!(" This is OK - not all worktrees have associated tickets"); + Ok(()) + } else { + let mut errors = Vec::new(); + let mut updated = 0; + + for ticket in &tickets { + match update_ticket_status(&ticket.id, "done") { + Ok(_) => { + println!("✓ Moved ticket to done: {} - {}", ticket.id, ticket.title); + updated += 1; + } + Err(e) => { + errors.push(format!("Failed to update {}: {}", ticket.id, e)); + } + } + } + + if !errors.is_empty() { + Err(errors.join("\n")) + } else { + if updated > 0 { + println!("✓ Moved {} ticket(s) to done", updated); + } + Ok(()) + } + } + } + "--help" | "-h" => { + print_usage(); + Ok(()) + } + _ => { + eprintln!("Error: Unknown command '{}'", command); + print_usage(); + std::process::exit(1); + } + }; + + if let Err(e) = result { + eprintln!("Error: {}", e); + std::process::exit(1); + } +} diff --git a/ushadow/launcher/src-tauri/src/commands/claude_sessions.rs b/ushadow/launcher/src-tauri/src/commands/claude_sessions.rs new file mode 100644 index 00000000..6a849aa6 --- /dev/null +++ b/ushadow/launcher/src-tauri/src/commands/claude_sessions.rs @@ -0,0 +1,551 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +/// A single event captured from a Claude Code session hook +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaudeSessionEvent { + pub event_type: String, + pub session_id: String, + pub cwd: String, + pub timestamp: String, // ISO 8601 + pub data: Value, // event-specific payload +} + +/// The Python hook script content, embedded at compile time. +/// Installed to ~/.claude/hooks/ushadow_launcher_hook.py +const HOOK_SCRIPT: &str = r#"#!/usr/bin/env python3 +# Ushadow Launcher - Claude Code session event logger +# Appends hook events to ~/.claude/ushadow_sessions.jsonl +import sys +import json +import os +import datetime + +try: + raw = sys.stdin.read() + data = json.loads(raw) if raw.strip() else {} +except Exception: + data = {} + +hook_name = data.get("hook_event_name", "unknown") +event = { + "event_type": hook_name, + "session_id": data.get("session_id", ""), + "cwd": data.get("cwd", ""), + "timestamp": datetime.datetime.utcnow().isoformat() + "Z", + "data": {}, +} + +if hook_name == "UserPromptSubmit": + msg = data.get("message", "") + event["data"] = {"message": msg[:500] if msg else ""} + +elif hook_name == "PreToolUse": + tool_input = data.get("tool_input", {}) + # Capture human-readable description or command as the headline + description = tool_input.get("description", "") or tool_input.get("command", "") + # Capture file path for Read/Write/Edit/Glob operations + path = tool_input.get("file_path", "") or tool_input.get("path", "") or tool_input.get("pattern", "") + event["data"] = { + "tool": data.get("tool_name", ""), + "tool_use_id": data.get("tool_use_id", ""), + "description": description[:300] if description else "", + "path": path[:300] if path else "", + } + +elif hook_name == "PostToolUse": + event["data"] = { + "tool": data.get("tool_name", ""), + "tool_use_id": data.get("tool_use_id", ""), + } + +elif hook_name == "Notification": + event["data"] = { + "message": data.get("message", ""), + "type": data.get("notification_type", ""), + "title": data.get("title", ""), + } + +elif hook_name == "Stop": + msg = data.get("last_assistant_message", "") + event["data"] = {"last_message": msg[:500] if msg else ""} + +elif hook_name in ("SessionStart", "SessionEnd", "SubagentStop"): + event["data"] = {"source": data.get("source", "")} + +elif hook_name == "PreCompact": + event["data"] = {"compaction_type": data.get("compaction_type", "")} + +log_path = os.path.expanduser("~/.claude/ushadow_sessions.jsonl") +os.makedirs(os.path.dirname(log_path), exist_ok=True) +with open(log_path, "a") as f: + f.write(json.dumps(event) + "\n") +"#; + +/// Hook entries to merge into ~/.claude/settings.json +const HOOK_COMMAND: &str = + "python3 ~/.claude/hooks/ushadow_launcher_hook.py"; + +fn get_claude_hooks_dir() -> Result { + let home = dirs::home_dir().ok_or("Could not determine home directory")?; + let hooks_dir = home.join(".claude").join("hooks"); + if !hooks_dir.exists() { + fs::create_dir_all(&hooks_dir) + .map_err(|e| format!("Failed to create hooks directory: {}", e))?; + } + Ok(hooks_dir) +} + +fn get_claude_settings_path() -> Result { + let home = dirs::home_dir().ok_or("Could not determine home directory")?; + let claude_dir = home.join(".claude"); + if !claude_dir.exists() { + fs::create_dir_all(&claude_dir) + .map_err(|e| format!("Failed to create .claude directory: {}", e))?; + } + Ok(claude_dir.join("settings.json")) +} + +fn get_sessions_file_path() -> Result { + let home = dirs::home_dir().ok_or("Could not determine home directory")?; + Ok(home.join(".claude").join("ushadow_sessions.jsonl")) +} + +/// Check if our hook command is already in a given event's hook array +fn is_already_registered(settings: &Value, event_name: &str) -> bool { + settings["hooks"][event_name] + .as_array() + .map(|arr| { + arr.iter().any(|entry| { + entry["hooks"] + .as_array() + .map(|h| h.iter().any(|handler| handler["command"].as_str() == Some(HOOK_COMMAND))) + .unwrap_or(false) + }) + }) + .unwrap_or(false) +} + +/// Install the Ushadow launcher hooks into Claude Code's global settings. +/// Merges our hook entries without overwriting existing user hooks. +#[tauri::command] +pub async fn install_claude_hooks() -> Result { + // 1. Write the Python hook script + let hooks_dir = get_claude_hooks_dir()?; + let script_path = hooks_dir.join("ushadow_launcher_hook.py"); + fs::write(&script_path, HOOK_SCRIPT) + .map_err(|e| format!("Failed to write hook script: {}", e))?; + + // Make it executable on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&script_path) + .map_err(|e| format!("Failed to read script permissions: {}", e))? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms) + .map_err(|e| format!("Failed to set script permissions: {}", e))?; + } + + // 2. Read existing ~/.claude/settings.json (or start fresh) + let settings_path = get_claude_settings_path()?; + let mut settings: Value = if settings_path.exists() { + let raw = fs::read_to_string(&settings_path) + .map_err(|e| format!("Failed to read settings.json: {}", e))?; + serde_json::from_str(&raw).unwrap_or(serde_json::json!({})) + } else { + serde_json::json!({}) + }; + + // 3. Ensure "hooks" key is an object + if !settings.get("hooks").map(|v| v.is_object()).unwrap_or(false) { + settings["hooks"] = serde_json::json!({}); + } + + let hook_handler = serde_json::json!({ + "type": "command", + "command": HOOK_COMMAND, + "async": true + }); + + // 4. Events without tool matchers + let simple_events = [ + "SessionStart", + "SessionEnd", + "UserPromptSubmit", + "SubagentStop", + "Stop", + "Notification", + ]; + + for event_name in &simple_events { + if !settings["hooks"][event_name].is_array() { + settings["hooks"][event_name] = serde_json::json!([]); + } + if !is_already_registered(&settings, event_name) { + let new_entry = serde_json::json!({ "hooks": [hook_handler.clone()] }); + settings["hooks"][event_name].as_array_mut().unwrap().push(new_entry); + } + } + + // 5. Events with wildcard tool matcher + let wildcard_events = ["PreToolUse", "PostToolUse"]; + for event_name in &wildcard_events { + if !settings["hooks"][event_name].is_array() { + settings["hooks"][event_name] = serde_json::json!([]); + } + if !is_already_registered(&settings, event_name) { + let new_entry = serde_json::json!({ + "matcher": "*", + "hooks": [hook_handler.clone()] + }); + settings["hooks"][event_name].as_array_mut().unwrap().push(new_entry); + } + } + + // 6. PreCompact needs separate auto/manual matcher entries + if !settings["hooks"]["PreCompact"].is_array() { + settings["hooks"]["PreCompact"] = serde_json::json!([]); + } + if !is_already_registered(&settings, "PreCompact") { + for compact_type in &["auto", "manual"] { + let new_entry = serde_json::json!({ + "matcher": compact_type, + "hooks": [hook_handler.clone()] + }); + settings["hooks"]["PreCompact"].as_array_mut().unwrap().push(new_entry); + } + } + + // 7. Write back + let json_out = serde_json::to_string_pretty(&settings) + .map_err(|e| format!("Failed to serialize settings: {}", e))?; + fs::write(&settings_path, json_out) + .map_err(|e| format!("Failed to write settings.json: {}", e))?; + + Ok(format!( + "Hooks installed to {} and {}", + script_path.display(), + settings_path.display() + )) +} + +/// Check whether the Ushadow launcher hook script is installed. +#[tauri::command] +pub async fn get_hooks_installed() -> Result { + let home = dirs::home_dir().ok_or("Could not determine home directory")?; + let script_path = home + .join(".claude") + .join("hooks") + .join("ushadow_launcher_hook.py"); + Ok(script_path.exists()) +} + +/// A single message in a Claude conversation transcript +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCallInfo { + pub name: String, + pub description: Option, + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TranscriptMessage { + pub role: String, // "user" | "assistant" + pub text: Option, // main text content (truncated to 2000 chars) + pub tools: Vec, // tool calls within this assistant turn + pub timestamp: String, + pub message_id: String, +} + +/// Extract clean user text, filtering out system injections +fn extract_user_text(content: &Value) -> Option { + let text = match content { + Value::String(s) => s.clone(), + Value::Array(blocks) => { + let parts: Vec<&str> = blocks + .iter() + .filter_map(|b| { + if b["type"].as_str() == Some("text") { + b["text"].as_str() + } else { + None + } + }) + .collect(); + parts.join(" ") + } + _ => return None, + }; + + let trimmed = text.trim(); + if trimmed.is_empty() + || trimmed.starts_with('<') + || trimmed.starts_with('[') + || trimmed.contains("") + || trimmed.contains("") + { + return None; + } + + if trimmed.len() > 1000 { + Some(format!("{}…", &trimmed[..1000])) + } else { + Some(trimmed.to_string()) + } +} + +/// Read the full conversation transcript for a specific Claude session. +/// Maps CWD to the ~/.claude/projects/{dir}/{session_id}.jsonl path. +/// Deduplicates streaming chunks by keeping only the last entry per message ID. +#[tauri::command] +pub async fn read_claude_transcript( + session_id: String, + cwd: String, +) -> Result, String> { + let home = dirs::home_dir().ok_or("Could not determine home directory")?; + + // Map CWD to project dir: replace "/" with "-" + // e.g., "/Users/stu/repos/foo" → "-Users-stu-repos-foo" + let project_dir = cwd.replace('/', "-"); + + let session_file = home + .join(".claude") + .join("projects") + .join(&project_dir) + .join(format!("{}.jsonl", session_id)); + + if !session_file.exists() { + return Ok(vec![]); + } + + let content = fs::read_to_string(&session_file) + .map_err(|e| format!("Failed to read transcript: {}", e))?; + + // Deduplicate by message_id — streaming sends incremental chunks sharing the same ID, + // so we only want the last (most complete) entry per ID, in original order. + let mut order: Vec = Vec::new(); + let mut by_id: HashMap = HashMap::new(); + + for line in content.lines().filter(|l| !l.trim().is_empty()) { + let obj: Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(_) => continue, + }; + + let entry_type = obj["type"].as_str().unwrap_or(""); + if entry_type != "user" && entry_type != "assistant" { + continue; + } + + let msg_id = if entry_type == "assistant" { + obj["message"]["id"].as_str().unwrap_or("").to_string() + } else { + obj["uuid"].as_str().unwrap_or("").to_string() + }; + + if msg_id.is_empty() { + continue; + } + + if !by_id.contains_key(&msg_id) { + order.push(msg_id.clone()); + } + by_id.insert(msg_id, obj); + } + + let mut messages: Vec = Vec::new(); + + for id in &order { + let obj = match by_id.get(id) { + Some(v) => v, + None => continue, + }; + + let entry_type = obj["type"].as_str().unwrap_or(""); + let msg = &obj["message"]; + let timestamp = obj["timestamp"].as_str().unwrap_or("").to_string(); + + match entry_type { + "user" => { + if let Some(text) = extract_user_text(&msg["content"]) { + messages.push(TranscriptMessage { + role: "user".to_string(), + text: Some(text), + tools: vec![], + timestamp, + message_id: id.clone(), + }); + } + } + "assistant" => { + let content = match msg["content"].as_array() { + Some(a) => a, + None => continue, + }; + + let mut text_parts: Vec = Vec::new(); + let mut tools: Vec = Vec::new(); + + for block in content { + match block["type"].as_str() { + Some("text") => { + if let Some(t) = block["text"].as_str() { + let trimmed = t.trim(); + if !trimmed.is_empty() { + text_parts.push(trimmed.to_string()); + } + } + } + Some("tool_use") => { + let name = block["name"].as_str().unwrap_or("").to_string(); + if name.is_empty() { + continue; + } + let input = &block["input"]; + let description = input["description"] + .as_str() + .or_else(|| input["command"].as_str()) + .or_else(|| input["prompt"].as_str()) + .map(|s| s.chars().take(200).collect::()); + let path = input["file_path"] + .as_str() + .or_else(|| input["path"].as_str()) + .or_else(|| input["pattern"].as_str()) + .map(|s| s.to_string()); + tools.push(ToolCallInfo { name, description, path }); + } + _ => {} // skip thinking, other block types + } + } + + let joined = text_parts.join("\n\n"); + let text = if joined.is_empty() { + None + } else if joined.len() > 2000 { + Some(format!("{}…", &joined[..2000])) + } else { + Some(joined) + }; + + if text.is_some() || !tools.is_empty() { + messages.push(TranscriptMessage { + role: "assistant".to_string(), + text, + tools, + timestamp, + message_id: id.clone(), + }); + } + } + _ => {} + } + } + + // Return the last 30 messages to keep the payload manageable + if messages.len() > 30 { + let skip = messages.len() - 30; + messages = messages.into_iter().skip(skip).collect(); + } + + Ok(messages) +} + +/// Send an approval (y) or denial (n) keystroke to the tmux pane running Claude +/// in the given working directory. Matches panes by longest path prefix, +/// preferring panes whose current command is claude/node. +#[tauri::command] +pub async fn send_claude_approval(cwd: String, approve: bool) -> Result { + let output = std::process::Command::new("tmux") + .args([ + "list-panes", "-a", "-F", + "#{pane_current_path}\t#{session_name}:#{window_index}.#{pane_index}\t#{pane_current_command}", + ]) + .output() + .map_err(|e| format!("tmux unavailable: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Normalize trailing slashes so /foo/ and /foo both match + let norm_cwd = cwd.trim_end_matches('/'); + + // Collect all matching panes: (path_match_len, is_claude_pane, target) + let mut candidates: Vec<(usize, bool, String)> = stdout + .lines() + .filter_map(|line| { + let mut parts = line.splitn(3, '\t'); + let pane_path = parts.next()?.trim_end_matches('/'); + let pane_target = parts.next()?; + let pane_cmd = parts.next().unwrap_or(""); + + let is_claude = pane_cmd.contains("claude") || pane_cmd.contains("node"); + + if norm_cwd.starts_with(pane_path) || pane_path.starts_with(norm_cwd) { + Some((pane_path.len(), is_claude, pane_target.to_string())) + } else { + None + } + }) + .collect(); + + // Sort: longest path match first, then prefer claude/node panes + candidates.sort_by(|(la, ca, _), (lb, cb, _)| lb.cmp(la).then(cb.cmp(ca))); + + let target = candidates + .into_iter() + .next() + .map(|(_, _, t)| t) + .ok_or_else(|| { + format!( + "No tmux pane found for: {}. Is claude running in tmux?", + cwd + ) + })?; + + let key = if approve { "y" } else { "n" }; + std::process::Command::new("tmux") + .args(["send-keys", "-t", &target, key, "Enter"]) + .output() + .map_err(|e| format!("Failed to send key: {}", e))?; + + Ok(format!("Sent '{}' to {}", key, target)) +} + +/// Read Claude session events from the JSONL log file. +/// Returns all events from the last 24 hours across all projects. +#[tauri::command] +pub async fn read_claude_sessions(_project_root: String) -> Result, String> { + let sessions_path = get_sessions_file_path()?; + + if !sessions_path.exists() { + return Ok(vec![]); + } + + let content = fs::read_to_string(&sessions_path) + .map_err(|e| format!("Failed to read sessions file: {}", e))?; + + let cutoff = chrono::Utc::now() - chrono::Duration::hours(24); + + let mut events: Vec = content + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|event| { + chrono::DateTime::parse_from_rfc3339(&event.timestamp) + .map(|ts| ts.with_timezone(&chrono::Utc) > cutoff) + .unwrap_or(true) + }) + .collect(); + + // Most recent 500 events to keep payload manageable + if events.len() > 500 { + let skip = events.len() - 500; + events = events.into_iter().skip(skip).collect(); + } + + Ok(events) +} diff --git a/ushadow/launcher/src-tauri/src/commands/config_commands.rs b/ushadow/launcher/src-tauri/src/commands/config_commands.rs new file mode 100644 index 00000000..a563cb94 --- /dev/null +++ b/ushadow/launcher/src-tauri/src/commands/config_commands.rs @@ -0,0 +1,48 @@ +use crate::config::LauncherConfig; +use std::path::PathBuf; +use tauri::State; + +use super::docker::AppState; + +/// Load project configuration from .launcher-config.yaml +#[tauri::command] +pub async fn load_project_config( + project_root: String, + state: State<'_, AppState>, +) -> Result { + let config = LauncherConfig::load(&PathBuf::from(&project_root))?; + + // Store the loaded config in application state + let mut config_lock = state.config.lock().map_err(|e| e.to_string())?; + *config_lock = Some(config.clone()); + + Ok(config) +} + +/// Get the currently loaded configuration +#[tauri::command] +pub async fn get_current_config( + state: State<'_, AppState>, +) -> Result, String> { + let config_lock = state.config.lock().map_err(|e| e.to_string())?; + Ok(config_lock.clone()) +} + +/// Check if a launcher config file exists in the given directory +#[tauri::command] +pub async fn check_launcher_config_exists(project_root: String) -> Result { + let config_path = PathBuf::from(&project_root).join(".launcher-config.yaml"); + Ok(config_path.exists()) +} + +/// Validate a config file without loading it into state +#[tauri::command] +pub async fn validate_config_file(project_root: String) -> Result { + match LauncherConfig::load(&PathBuf::from(&project_root)) { + Ok(config) => Ok(format!( + "Configuration is valid for project '{}'", + config.project.display_name + )), + Err(e) => Err(e), + } +} diff --git a/ushadow/launcher/src-tauri/src/commands/container_discovery.rs b/ushadow/launcher/src-tauri/src/commands/container_discovery.rs new file mode 100644 index 00000000..11401873 --- /dev/null +++ b/ushadow/launcher/src-tauri/src/commands/container_discovery.rs @@ -0,0 +1,307 @@ +use crate::config::LauncherConfig; +use crate::models::{EnvironmentStatus, InfraService, UshadowEnvironment}; +use serde_json::Value; +use super::utils::silent_command; + +/// Information about a discovered container +#[derive(Debug, Clone)] +pub struct ContainerInfo { + pub name: String, + pub service_name: String, + pub status: String, + pub ports: Vec, + pub compose_project: String, +} + +/// Port mapping from container to host +#[derive(Debug, Clone)] +pub struct PortMapping { + pub host_port: u16, + pub container_port: u16, + pub protocol: String, +} + +/// Discover all containers for a specific environment using Docker Compose labels +pub fn discover_environment_containers( + config: &LauncherConfig, + env_name: &str, +) -> Result, String> { + // Determine the compose project name for this environment + // For ushadow: "ushadow-orange", "ushadow-blue", or "ushadow" for default + let compose_project = if env_name == "default" || env_name.is_empty() { + config.project.name.clone() + } else { + format!("{}-{}", config.project.name, env_name) + }; + + // Query Docker for containers with this compose project label + let output = silent_command("docker") + .args([ + "ps", + "-a", + "--filter", + &format!("label=com.docker.compose.project={}", compose_project), + "--format", + "{{.Names}}", + ]) + .output() + .map_err(|e| format!("Failed to query Docker: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Docker command failed: {}", stderr)); + } + + let container_names = String::from_utf8_lossy(&output.stdout); + let mut containers = Vec::new(); + + for container_name in container_names.lines() { + if container_name.trim().is_empty() { + continue; + } + + // Inspect each container to get detailed information + if let Ok(info) = inspect_container(container_name) { + containers.push(info); + } + } + + Ok(containers) +} + +/// Inspect a single container to extract service name, status, and ports +fn inspect_container(container_name: &str) -> Result { + let output = silent_command("docker") + .args(["inspect", container_name]) + .output() + .map_err(|e| format!("Failed to inspect container: {}", e))?; + + if !output.status.success() { + return Err("Docker inspect failed".to_string()); + } + + let json_str = String::from_utf8_lossy(&output.stdout); + let json: Vec = serde_json::from_str(&json_str) + .map_err(|e| format!("Failed to parse Docker inspect JSON: {}", e))?; + + let container = json + .first() + .ok_or("No container info returned".to_string())?; + + // Extract labels + let labels = container["Config"]["Labels"] + .as_object() + .ok_or("No labels found")?; + + let service_name = labels + .get("com.docker.compose.service") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let compose_project = labels + .get("com.docker.compose.project") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // Extract status + let status = container["State"]["Status"] + .as_str() + .unwrap_or("unknown") + .to_string(); + + // Extract port mappings + let ports = extract_port_mappings(container)?; + + Ok(ContainerInfo { + name: container_name.to_string(), + service_name, + status, + ports, + compose_project, + }) +} + +/// Extract port mappings from Docker inspect JSON +fn extract_port_mappings(container: &Value) -> Result, String> { + let mut mappings = Vec::new(); + + let ports_obj = match container["NetworkSettings"]["Ports"].as_object() { + Some(obj) => obj, + None => return Ok(mappings), // No ports exposed + }; + + for (container_port_proto, host_bindings) in ports_obj { + // container_port_proto format: "8000/tcp" + let parts: Vec<&str> = container_port_proto.split('/').collect(); + if parts.len() != 2 { + continue; + } + + let container_port = parts[0].parse::().unwrap_or(0); + let protocol = parts[1].to_string(); + + // host_bindings is an array of {"HostIp": "0.0.0.0", "HostPort": "8240"} + if let Some(bindings) = host_bindings.as_array() { + for binding in bindings { + if let Some(host_port_str) = binding["HostPort"].as_str() { + if let Ok(host_port) = host_port_str.parse::() { + mappings.push(PortMapping { + host_port, + container_port, + protocol: protocol.clone(), + }); + } + } + } + } + } + + Ok(mappings) +} + +/// Discover infrastructure containers using compose project label +pub fn discover_infrastructure_containers( + config: &LauncherConfig, +) -> Result, String> { + let output = silent_command("docker") + .args([ + "ps", + "-a", + "--filter", + &format!( + "label=com.docker.compose.project={}", + config.infrastructure.project_name + ), + "--format", + "{{.Names}}", + ]) + .output() + .map_err(|e| format!("Failed to query Docker: {}", e))?; + + if !output.status.success() { + return Ok(Vec::new()); // Infrastructure not running + } + + let container_names = String::from_utf8_lossy(&output.stdout); + let mut services = Vec::new(); + + for container_name in container_names.lines() { + if container_name.trim().is_empty() { + continue; + } + + if let Ok(info) = inspect_container(container_name) { + // Format ports string for display + let ports_str = if info.ports.is_empty() { + None + } else { + Some( + info.ports + .iter() + .map(|p| format!("{}:{}", p.host_port, p.container_port)) + .collect::>() + .join(", "), + ) + }; + + services.push(InfraService { + name: info.service_name.clone(), + display_name: capitalize(&info.service_name), + running: info.status == "running", + ports: ports_str, + }); + } + } + + Ok(services) +} + +/// Capitalize first letter of a string +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(chars).collect(), + } +} + +/// Determine environment status from container list +pub fn determine_environment_status(containers: &[ContainerInfo]) -> EnvironmentStatus { + if containers.is_empty() { + return EnvironmentStatus::Available; + } + + let running_count = containers.iter().filter(|c| c.status == "running").count(); + + if running_count == containers.len() { + EnvironmentStatus::Running + } else if running_count > 0 { + EnvironmentStatus::Partial + } else { + EnvironmentStatus::Stopped + } +} + +/// Find the primary service port from container list +pub fn get_primary_service_port( + containers: &[ContainerInfo], + primary_service_name: &str, +) -> Option { + containers + .iter() + .find(|c| c.service_name == primary_service_name) + .and_then(|c| c.ports.first()) + .map(|p| p.host_port) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_capitalize() { + assert_eq!(capitalize("mongo"), "Mongo"); + assert_eq!(capitalize("redis"), "Redis"); + assert_eq!(capitalize(""), ""); + } + + #[test] + fn test_determine_status() { + let running_containers = vec![ + ContainerInfo { + name: "test-backend".to_string(), + service_name: "backend".to_string(), + status: "running".to_string(), + ports: vec![], + compose_project: "test".to_string(), + }, + ContainerInfo { + name: "test-webui".to_string(), + service_name: "webui".to_string(), + status: "running".to_string(), + ports: vec![], + compose_project: "test".to_string(), + }, + ]; + + assert_eq!( + determine_environment_status(&running_containers), + EnvironmentStatus::Running + ); + + let mut partial_containers = running_containers.clone(); + partial_containers[1].status = "exited".to_string(); + + assert_eq!( + determine_environment_status(&partial_containers), + EnvironmentStatus::Partial + ); + + assert_eq!( + determine_environment_status(&[]), + EnvironmentStatus::Available + ); + } +} diff --git a/ushadow/launcher/src-tauri/src/commands/discovery.rs b/ushadow/launcher/src-tauri/src/commands/discovery.rs index 363c9c00..de60653f 100644 --- a/ushadow/launcher/src-tauri/src/commands/discovery.rs +++ b/ushadow/launcher/src-tauri/src/commands/discovery.rs @@ -5,15 +5,113 @@ use crate::models::{DiscoveryResult, EnvironmentStatus, InfraService, UshadowEnv use super::prerequisites::{check_docker, check_tailscale}; use super::utils::silent_command; use super::worktree::{list_worktrees, get_colors_for_name}; +use super::bundled; -/// Infrastructure service patterns +/// Infrastructure service patterns (fallback when compose file not available) const INFRA_PATTERNS: &[(&str, &str)] = &[ ("mongo", "MongoDB"), + ("postgres", "PostgreSQL"), ("redis", "Redis"), ("neo4j", "Neo4j"), ("qdrant", "Qdrant"), + ("casdoor", "Casdoor"), + ("ollama", "Ollama"), ]; +/// Display name overrides for well-known service IDs +fn get_display_name(service_name: &str) -> String { + match service_name { + "mongo" | "mongodb" => "MongoDB", + "postgres" => "PostgreSQL", + "redis" => "Redis", + "neo4j" => "Neo4j", + "qdrant" => "Qdrant", + "casdoor" => "Casdoor", + "ollama" => "Ollama", + "mysql" => "MySQL", + "elasticsearch" => "Elasticsearch", + "rabbitmq" => "RabbitMQ", + "kafka" => "Kafka", + other => return other.to_string(), + }.to_string() +} + +/// A resolved infra service pattern derived from the compose file +struct InfraPattern { + /// The container name to match against `docker ps` output + container_name: String, + /// Canonical service ID (compose service key) + service_name: String, + /// Human-readable display name + display_name: String, +} + +/// Load infra service patterns from docker-compose.infra.yml. +/// Falls back to an empty vec on any error; callers should then fall back to INFRA_PATTERNS. +fn load_compose_infra_patterns(project_root: &str) -> Vec { + use serde_yaml::Value; + + // Prefer the project's own compose file; fall back to the bundled one + let project_compose = std::path::Path::new(project_root) + .join("compose") + .join("docker-compose.infra.yml"); + + let bundled_compose = bundled::get_compose_file(project_root, "docker-compose.infra.yml"); + + let compose_path = if project_compose.exists() { + project_compose + } else { + bundled_compose + }; + + let contents = match std::fs::read_to_string(&compose_path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + let yaml: Value = match serde_yaml::from_str(&contents) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + + let services = match yaml.get("services").and_then(|s| s.as_mapping()) { + Some(m) => m, + None => return Vec::new(), + }; + + // Compose project name (used when no explicit container_name) + let project_name = yaml.get("name") + .and_then(|n| n.as_str()) + .unwrap_or("infra"); + + let mut patterns = Vec::new(); + + for (key, config) in services { + let service_name = match key.as_str() { + Some(s) => s.to_string(), + None => continue, + }; + + // Skip one-off init / migration containers + if service_name.ends_with("-init") || service_name.ends_with("-migration") || service_name.ends_with("-setup") { + continue; + } + + // Use explicit container_name if present; otherwise Compose defaults to + // "{project_name}-{service_name}-1" + let container_name = config.get("container_name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("{}-{}-1", project_name, service_name)); + + let display_name = get_display_name(&service_name); + + patterns.push(InfraPattern { container_name, service_name, display_name }); + } + + patterns +} + /// Read ports from environment's .env file /// Returns (backend_port, webui_port) fn read_env_ports(worktree_path: &str) -> (Option, Option) { @@ -118,6 +216,15 @@ pub async fn discover_environments_with_config( } }; + // Extract project_name from main_repo if available + let project_name = main_repo.as_ref().and_then(|repo| { + std::path::Path::new(repo) + .file_name() + .and_then(|name| name.to_str()) + .map(|s| s.to_string()) + }); + + let docker_ok = docker_installed && docker_running; // Default paths @@ -142,6 +249,10 @@ pub async fn discover_environments_with_config( worktree_map.insert(wt.name.clone(), wt); } + // Load infra service definitions from compose file (dynamic), fall back to static list + let compose_patterns = load_compose_infra_patterns(&main_repo); + let use_compose = !compose_patterns.is_empty(); + // Infrastructure and environment maps let mut infrastructure = Vec::new(); let mut found_infra = HashSet::new(); @@ -167,17 +278,55 @@ pub async fn discover_environments_with_config( let ports = if parts.len() > 2 { Some(parts[2].trim().to_string()) } else { None }; let is_running = status.contains("Up"); - // Check infrastructure services - for (pattern, display_name) in INFRA_PATTERNS { - if name == *pattern || name.ends_with(&format!("-{}", pattern)) || name.ends_with(&format!("-{}-1", pattern)) { - if !found_infra.contains(*pattern) { - found_infra.insert(pattern.to_string()); - infrastructure.push(InfraService { - name: pattern.to_string(), - display_name: display_name.to_string(), - running: is_running, - ports: ports.clone(), - }); + // Match against infra services from compose file (or static fallback) + if use_compose { + for pattern in &compose_patterns { + // Primary match: exact container_name from compose + // Fallback: fuzzy suffix match for containers without explicit container_name + let is_match = name == pattern.container_name + || name.ends_with(&format!("-{}", pattern.service_name)) + || name.ends_with(&format!("_{}", pattern.service_name)); + + if is_match { + if !found_infra.contains(&pattern.service_name) { + found_infra.insert(pattern.service_name.clone()); + infrastructure.push(InfraService { + name: pattern.service_name.clone(), + display_name: pattern.display_name.clone(), + running: is_running, + ports: ports.clone(), + }); + } else if is_running { + if let Some(service) = infrastructure.iter_mut().find(|s| s.name == pattern.service_name) { + service.running = true; + if ports.is_some() { service.ports = ports.clone(); } + } + } + } + } + } else { + // Static fallback (no compose file found) + for (pattern, display_name) in INFRA_PATTERNS { + let is_match = name == *pattern + || name.ends_with(&format!("-{}", pattern)) + || name.ends_with(&format!("-{}-1", pattern)) + || name.ends_with(&format!("_{}", pattern)) + || name.contains(&format!("_{}", pattern)); + + if is_match { + if !found_infra.contains(*pattern) { + found_infra.insert(pattern.to_string()); + infrastructure.push(InfraService { + name: pattern.to_string(), + display_name: display_name.to_string(), + running: is_running, + ports: ports.clone(), + }); + } else if is_running { + if let Some(service) = infrastructure.iter_mut().find(|s| s.name == *pattern) { + service.running = true; + } + } } } } @@ -304,6 +453,7 @@ pub async fn discover_environments_with_config( is_worktree: true, created_at: final_created_at, base_branch, + project_name: project_name.clone(), }); } @@ -365,6 +515,7 @@ pub async fn discover_environments_with_config( is_worktree: false, created_at: info.created_at, base_branch, + project_name: project_name.clone(), }); } diff --git a/ushadow/launcher/src-tauri/src/commands/discovery_v2.rs b/ushadow/launcher/src-tauri/src/commands/discovery_v2.rs new file mode 100644 index 00000000..455adf7e --- /dev/null +++ b/ushadow/launcher/src-tauri/src/commands/discovery_v2.rs @@ -0,0 +1,144 @@ +use crate::config::LauncherConfig; +use crate::models::{DiscoveryResult, EnvironmentStatus, UshadowEnvironment, WorktreeInfo}; +use super::container_discovery::{ + discover_environment_containers, discover_infrastructure_containers, + determine_environment_status, get_primary_service_port, +}; +use super::prerequisites::{check_docker, check_tailscale}; +use super::worktree::{list_worktrees, get_colors_for_name}; +use std::collections::HashMap; + +/// Discover environments using config-based Docker Compose labels +/// Note: The project_root parameter is required to load the config +/// The frontend should provide this from the user's project selection +#[tauri::command] +pub async fn discover_environments_v2( + project_root: String, + main_repo: Option, +) -> Result { + // Check prerequisites + let (docker_installed, docker_running, _) = check_docker(); + let (tailscale_installed, tailscale_connected, _) = check_tailscale(); + + let docker_ok = docker_installed && docker_running; + let tailscale_ok = tailscale_installed && tailscale_connected; + + // Load config from project root + let config = LauncherConfig::load(&std::path::PathBuf::from(&project_root))?; + + // Use provided main_repo or default to project_root + let main_repo = main_repo.unwrap_or_else(|| project_root.clone()); + + // Get worktrees (source of truth for environments) + let worktrees = match list_worktrees(main_repo.clone()).await { + Ok(wt) => { + eprintln!("[discovery_v2] Found {} worktrees from {}", wt.len(), main_repo); + wt + } + Err(e) => { + eprintln!("[discovery_v2] Failed to list worktrees from {}: {}", main_repo, e); + Vec::new() + } + }; + + // Build worktree map + let mut worktree_map: HashMap = HashMap::new(); + for wt in worktrees { + worktree_map.insert(wt.name.clone(), wt); + } + + // Discover infrastructure + let infrastructure = if docker_ok { + discover_infrastructure_containers(&config).unwrap_or_else(|e| { + eprintln!("[discovery_v2] Infrastructure discovery error: {}", e); + Vec::new() + }) + } else { + Vec::new() + }; + + // Discover environments + let mut environments = Vec::new(); + + for (env_name, wt) in &worktree_map { + let (primary_color, _) = get_colors_for_name(env_name); + + // Discover containers for this environment using Docker Compose labels + let containers = if docker_ok { + discover_environment_containers(&config, env_name).unwrap_or_else(|e| { + eprintln!("[discovery_v2] Container discovery error for {}: {}", env_name, e); + Vec::new() + }) + } else { + Vec::new() + }; + + // Determine status from containers + let status = determine_environment_status(&containers); + + // Get primary service port + let backend_port = get_primary_service_port(&containers, &config.containers.primary_service); + + // Find webui port from containers (look for webui service) + // Falls back to backend - 5000 if webui service not found + let webui_port = containers + .iter() + .find(|c| c.service_name == "webui" || c.service_name == "frontend") + .and_then(|c| c.ports.first()) + .map(|p| p.host_port) + .or_else(|| backend_port.and_then(|p| if p >= 5000 { Some(p - 5000) } else { None })); + + // Build localhost URL (prefer webui port, fallback to backend) + let localhost_url = if status == EnvironmentStatus::Running { + webui_port.or(backend_port).map(|p| format!("http://localhost:{}", p)) + } else { + None + }; + + // Generate Tailscale URL using the host's tailnet + let tailscale_url = super::port_utils::generate_tailscale_url( + env_name, + config.containers.tailscale_project_prefix.as_deref(), + ) + .unwrap_or(None); + + let tailscale_active = tailscale_url.is_some() && status == EnvironmentStatus::Running; + + // Container names for display + let container_names: Vec = containers.iter().map(|c| c.name.clone()).collect(); + + let running = status == EnvironmentStatus::Running || status == EnvironmentStatus::Partial; + + // Extract project name from project_root path (last directory name) + let project_name = std::path::Path::new(&project_root) + .file_name() + .and_then(|name| name.to_str()) + .map(|s| s.to_string()); + + environments.push(UshadowEnvironment { + name: env_name.clone(), + color: primary_color, + path: Some(wt.path.clone()), + branch: Some(wt.branch.clone()), + status, + running, + localhost_url, + tailscale_url, + backend_port, + webui_port, + tailscale_active, + containers: container_names, + is_worktree: true, + created_at: None, // TODO: Get actual creation timestamp from git worktree + base_branch: None, // TODO: Determine base branch (main/dev) from worktree + project_name, + }); + } + + Ok(DiscoveryResult { + infrastructure, + environments, + docker_ok, + tailscale_ok, + }) +} diff --git a/ushadow/launcher/src-tauri/src/commands/docker.rs b/ushadow/launcher/src-tauri/src/commands/docker.rs index 31a57fba..e588ff7d 100644 --- a/ushadow/launcher/src-tauri/src/commands/docker.rs +++ b/ushadow/launcher/src-tauri/src/commands/docker.rs @@ -3,10 +3,12 @@ use std::sync::Mutex; use std::collections::HashMap; use std::path::Path; use tauri::State; -use crate::models::{ContainerStatus, ServiceInfo}; +use crate::models::{ContainerStatus, ServiceInfo, InfraService}; use super::utils::{silent_command, shell_command, quote_path_buf}; use super::platform::{Platform, PlatformOps}; use super::bundled; +use crate::config::LauncherConfig; +use serde_yaml::Value; /// Recursively copy a directory and all its contents fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { @@ -122,12 +124,16 @@ fn find_available_ports(default_backend: u16, default_webui: u16) -> (u16, u16) /// Application state pub struct AppState { pub project_root: Mutex>, + pub containers_running: Mutex, + pub config: Mutex>, } impl AppState { pub fn new() -> Self { Self { project_root: Mutex::new(None), + containers_running: Mutex::new(false), + config: Mutex::new(None), } } } @@ -166,7 +172,7 @@ pub async fn start_infrastructure(state: State<'_, AppState>) -> Result) -> Result) -> Result, env_name: String, env // Note: Removed --skip-admin flag so admin user can be auto-created from secrets.yaml status_log.push(format!("Running setup script...")); - // Get bundled setup scripts if available - let bundled_setup_dir = bundled::get_setup_dir(&working_dir); + // Check if this worktree has a .launcher-config.yaml with a custom setup command + let mut setup_command = None; + let config_path = Path::new(&working_dir).join(".launcher-config.yaml"); + if config_path.exists() { + debug_log.push(format!("Found .launcher-config.yaml at: {:?}", config_path)); + match LauncherConfig::load(&Path::new(&working_dir).to_path_buf()) { + Ok(config) => { + if !config.setup.command.is_empty() { + setup_command = Some(config.setup.command.clone()); + debug_log.push(format!("Using custom setup command from config: {}", config.setup.command)); + } else { + debug_log.push(format!("Config exists but setup.command is empty, falling back to default")); + } + } + Err(e) => { + debug_log.push(format!("Failed to load config: {}, falling back to default", e)); + } + } + } else { + debug_log.push(format!("No .launcher-config.yaml found, using default ushadow setup")); + } - // Copy bundled setup to working directory if it's from the bundled location - // This avoids permission issues on Windows where Program Files requires admin - let working_setup_dir = std::path::Path::new(&working_dir).join("setup"); + let command = if let Some(custom_command) = setup_command { + // Use the custom command from the config + custom_command + } else { + // Fall back to default ushadow setup + // Get bundled setup scripts if available + let bundled_setup_dir = bundled::get_setup_dir(&working_dir); - if bundled_setup_dir != working_setup_dir { - debug_log.push(format!("Copying bundled setup from {:?} to {:?}", bundled_setup_dir, working_setup_dir)); + // Copy bundled setup to working directory if it's from the bundled location + // This avoids permission issues on Windows where Program Files requires admin + let working_setup_dir = std::path::Path::new(&working_dir).join("setup"); - // Recursively copy the entire setup directory - if let Err(e) = copy_dir_recursive(&bundled_setup_dir, &working_setup_dir) { - debug_log.push(format!("Warning: Failed to copy setup directory: {}", e)); - // Continue anyway - might be a partial copy that still works - } else { - debug_log.push(format!("✓ Bundled setup copied successfully")); + if bundled_setup_dir != working_setup_dir { + debug_log.push(format!("Copying bundled setup from {:?} to {:?}", bundled_setup_dir, working_setup_dir)); + + // Recursively copy the entire setup directory + if let Err(e) = copy_dir_recursive(&bundled_setup_dir, &working_setup_dir) { + debug_log.push(format!("Warning: Failed to copy setup directory: {}", e)); + // Continue anyway - might be a partial copy that still works + } else { + debug_log.push(format!("[OK] Bundled setup copied successfully")); + } } - } - let run_py_path = working_setup_dir.join("run.py"); - let run_py_quoted = quote_path_buf(&run_py_path); + let run_py_path = working_setup_dir.join("run.py"); + let run_py_quoted = quote_path_buf(&run_py_path); + + debug_log.push(format!("Using setup script: {:?}", run_py_path)); + debug_log.push(format!("Running: {} run --with pyyaml {} --dev --quick", uv_cmd, run_py_quoted)); - debug_log.push(format!("Using setup script: {:?}", run_py_path)); - debug_log.push(format!("Running: {} run --with pyyaml {} --dev --quick", uv_cmd, run_py_quoted)); + format!("{} run --with pyyaml {} --dev --quick", uv_cmd, run_py_quoted) + }; // Build the full command string using platform abstraction // Pass PORT_OFFSET for compatibility with both old and new setup scripts @@ -483,7 +519,6 @@ pub async fn start_environment(state: State<'_, AppState>, env_name: String, env env_vars.insert("ENV_NAME".to_string(), env_name.clone()); env_vars.insert("PORT_OFFSET".to_string(), port_offset.to_string()); - let command = format!("{} run --with pyyaml {} --dev --quick", uv_cmd, run_py_quoted); let setup_command = Platform::build_env_command(&working_dir, env_vars, &command); let output = shell_command(&setup_command) @@ -536,7 +571,7 @@ pub async fn start_environment(state: State<'_, AppState>, env_name: String, env } // On success, only show status log - status_log.push(format!("✓ Environment '{}' initialized and started", env_name)); + status_log.push(format!("[OK] Environment '{}' initialized and started", env_name)); return Ok(status_log.join("\n")); } @@ -837,6 +872,137 @@ pub async fn create_environment(state: State<'_, AppState>, name: String, mode: Ok(format!("Environment '{}' started{}", name, port_info)) } +/// Parse docker-compose.infra.yml to get list of available services +#[tauri::command] +pub fn get_infra_services_from_compose(state: State) -> Result, String> { + let root = state.project_root.lock().map_err(|e| e.to_string())?; + let project_root = root.clone().ok_or("Project root not set")?; + drop(root); + + // Try to find compose file (bundled or working directory) + let bundled_compose_file = bundled::get_compose_file(&project_root, "docker-compose.infra.yml"); + + // Also check working directory + let working_compose_file = std::path::Path::new(&project_root) + .join("compose") + .join("docker-compose.infra.yml"); + + let compose_file = if working_compose_file.exists() { + working_compose_file + } else { + bundled_compose_file + }; + + if !compose_file.exists() { + return Err(format!("docker-compose.infra.yml not found at {:?}", compose_file)); + } + + // Read and parse YAML + let contents = std::fs::read_to_string(&compose_file) + .map_err(|e| format!("Failed to read compose file: {}", e))?; + + let yaml: Value = serde_yaml::from_str(&contents) + .map_err(|e| format!("Failed to parse compose YAML: {}", e))?; + + // Extract services + let services = yaml.get("services") + .ok_or("No 'services' section found in compose file")? + .as_mapping() + .ok_or("'services' is not a mapping")?; + + let mut result = Vec::new(); + + // Map of service IDs to display names + let display_names: HashMap<&str, &str> = [ + ("postgres", "PostgreSQL"), + ("mongodb", "MongoDB"), + ("mongo", "MongoDB"), + ("redis", "Redis"), + ("mysql", "MySQL"), + ("elasticsearch", "Elasticsearch"), + ("rabbitmq", "RabbitMQ"), + ("kafka", "Kafka"), + ("qdrant", "Qdrant"), + ("neo4j", "Neo4j"), + ("casdoor", "Casdoor"), + ("ollama", "Ollama"), + ].iter().copied().collect(); + + for (service_id, service_config) in services { + let service_name = service_id.as_str() + .ok_or("Service name is not a string")? + .to_string(); + + // Skip one-off init containers (restart: "no" with -init suffix) + if service_name.ends_with("-init") || service_name.ends_with("-migration") || service_name.ends_with("-setup") { + continue; + } + + // Get display name (capitalize if not in map) + let display_name = display_names.get(service_name.as_str()) + .copied() + .unwrap_or_else(|| &service_name) + .to_string(); + + // Extract default port from exposed ports + // Handles plain "5432:5432", bare "5432", and Docker Compose shell expressions "${VAR:-8081}:8080" + let default_port = service_config.get("ports") + .and_then(|ports| ports.as_sequence()) + .and_then(|seq| seq.first()) + .and_then(|port_mapping| port_mapping.as_str()) + .and_then(|mapping| super::port_utils::parse_compose_host_port(mapping)); + + // Extract profiles + let profiles = service_config.get("profiles") + .and_then(|p| p.as_sequence()) + .map(|seq| { + seq.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_else(Vec::new); + + // Check if this service is actually running + let running = check_service_running(&service_name); + + // Format ports string + let ports_str = default_port.map(|p| p.to_string()); + + result.push(InfraService { + name: service_name, + display_name, + running, + ports: ports_str, + }); + } + + Ok(result) +} + +/// Check if a service container is running +fn check_service_running(service_name: &str) -> bool { + // Check if container exists and is running + let output = silent_command("docker") + .args([ + "ps", + "--filter", + &format!("name={}", service_name), + "--filter", + "status=running", + "--format", + "{{.Names}}", + ]) + .output(); + + match output { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.lines().any(|line| line.contains(service_name)) + } + _ => false, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/ushadow/launcher/src-tauri/src/commands/env_scanner.rs b/ushadow/launcher/src-tauri/src/commands/env_scanner.rs new file mode 100644 index 00000000..a15f2e5b --- /dev/null +++ b/ushadow/launcher/src-tauri/src/commands/env_scanner.rs @@ -0,0 +1,236 @@ +use std::path::Path; +use std::fs; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct DetectedPort { + pub name: String, + pub default_value: Option, + pub base_port: Option, + pub is_database: bool, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct DetectedEnvVar { + pub name: String, + pub default_value: Option, + pub is_port: bool, + pub is_database_port: bool, + pub should_append_env_name: bool, // For DB names, user names, etc. +} + +/// Scan .env.template, .env.example, or .env for port-related variables +#[tauri::command] +pub fn scan_env_file(project_root: String) -> Result, String> { + let project_path = Path::new(&project_root); + + // Try different env file names in order of preference + let env_files = vec![ + ".env.template", + ".env.example", + ".env.sample", + ".env", + ]; + + let mut found_file = None; + for file_name in &env_files { + let file_path = project_path.join(file_name); + if file_path.exists() { + found_file = Some(file_path); + break; + } + } + + let env_file = found_file.ok_or("No .env file found in project root")?; + + eprintln!("[scan_env_file] Scanning: {:?}", env_file); + + let content = fs::read_to_string(&env_file) + .map_err(|e| format!("Failed to read env file: {}", e))?; + + let mut detected_ports = Vec::new(); + + for line in content.lines() { + let line = line.trim(); + + // Skip comments and empty lines + if line.starts_with('#') || line.is_empty() { + continue; + } + + // Parse VAR=value format + if let Some((key, value)) = line.split_once('=') { + let key = key.trim(); + let value = value.trim().trim_matches('"').trim_matches('\''); + + // Check if this looks like a port variable + if is_port_variable(key) { + let base_port = value.parse::().ok(); + let is_database = is_database_port(key); + + detected_ports.push(DetectedPort { + name: key.to_string(), + default_value: Some(value.to_string()), + base_port, + is_database, + }); + } + } + } + + eprintln!("[scan_env_file] Detected {} port variables", detected_ports.len()); + + Ok(detected_ports) +} + +/// Scan all environment variables from .env files +#[tauri::command] +pub fn scan_all_env_vars(project_root: String) -> Result, String> { + let project_path = Path::new(&project_root); + + // Try different env file names in order of preference + let env_files = vec![ + ".env.template", + ".env.example", + ".env.sample", + ".env", + ]; + + let mut found_file = None; + for file_name in &env_files { + let file_path = project_path.join(file_name); + if file_path.exists() { + found_file = Some(file_path); + break; + } + } + + let env_file = found_file.ok_or("No .env file found in project root")?; + + eprintln!("[scan_all_env_vars] Scanning: {:?}", env_file); + + let content = fs::read_to_string(&env_file) + .map_err(|e| format!("Failed to read env file: {}", e))?; + + let mut detected_vars = Vec::new(); + + for line in content.lines() { + let line = line.trim(); + + // Skip comments and empty lines + if line.starts_with('#') || line.is_empty() { + continue; + } + + // Parse VAR=value format + if let Some((key, value)) = line.split_once('=') { + let key = key.trim(); + let value = value.trim().trim_matches('"').trim_matches('\''); + + let is_port = is_port_variable(key); + let is_database_port = is_database_port(key); + let should_append_env_name = should_append_env_name(key); + + detected_vars.push(DetectedEnvVar { + name: key.to_string(), + default_value: Some(value.to_string()), + is_port, + is_database_port, + should_append_env_name, + }); + } + } + + eprintln!("[scan_all_env_vars] Detected {} variables", detected_vars.len()); + + Ok(detected_vars) +} + +/// Check if a variable name looks like a port variable +fn is_port_variable(key: &str) -> bool { + let key_upper = key.to_uppercase(); + + // Explicit port variables + if key_upper.contains("PORT") { + return true; + } + + // Common database/service port patterns + let patterns = [ + "POSTGRES", "MYSQL", "MONGODB", "MONGO", "REDIS", "MEMCACHED", + "ELASTICSEARCH", "RABBITMQ", "KAFKA", "CASSANDRA", + "BACKEND", "API", "WEBUI", "FRONTEND", "WEB", + ]; + + for pattern in &patterns { + if key_upper.contains(pattern) && key_upper.contains("PORT") { + return true; + } + } + + false +} + +/// Check if a port variable is for a database +fn is_database_port(key: &str) -> bool { + let key_upper = key.to_uppercase(); + + let db_keywords = [ + "POSTGRES", "MYSQL", "MONGODB", "MONGO", "REDIS", "MEMCACHED", + "ELASTICSEARCH", "CASSANDRA", "MARIADB", "MSSQL", "ORACLE", + "DB", "DATABASE", + ]; + + db_keywords.iter().any(|kw| key_upper.contains(kw)) +} + +/// Check if a variable should have the environment name appended +/// (e.g., database names, user names, bucket names) +fn should_append_env_name(key: &str) -> bool { + let key_upper = key.to_uppercase(); + + // Variables that typically need env-specific values + let patterns = [ + "DB_NAME", "DATABASE_NAME", "POSTGRES_DB", "MYSQL_DATABASE", "MONGO_DATABASE", + "DB_USER", "DATABASE_USER", "POSTGRES_USER", "MYSQL_USER", + "BUCKET_NAME", "QUEUE_NAME", "TOPIC_NAME", "STREAM_NAME", + "SCHEMA_NAME", "TENANT_", "NAMESPACE", + ]; + + patterns.iter().any(|pattern| key_upper.contains(pattern)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_port_variable() { + assert!(is_port_variable("BACKEND_PORT")); + assert!(is_port_variable("postgres_port")); + assert!(is_port_variable("REDIS_PORT")); + assert!(is_port_variable("API_PORT")); + assert!(!is_port_variable("API_KEY")); + assert!(!is_port_variable("DATABASE_URL")); + } + + #[test] + fn test_is_database_port() { + assert!(is_database_port("POSTGRES_PORT")); + assert!(is_database_port("REDIS_PORT")); + assert!(is_database_port("MONGODB_PORT")); + assert!(!is_database_port("BACKEND_PORT")); + assert!(!is_database_port("WEBUI_PORT")); + } + + #[test] + fn test_should_append_env_name() { + assert!(should_append_env_name("POSTGRES_DB")); + assert!(should_append_env_name("DB_NAME")); + assert!(should_append_env_name("DATABASE_NAME")); + assert!(should_append_env_name("BUCKET_NAME")); + assert!(should_append_env_name("POSTGRES_USER")); + assert!(!should_append_env_name("POSTGRES_PASSWORD")); + assert!(!should_append_env_name("API_KEY")); + } +} diff --git a/ushadow/launcher/src-tauri/src/commands/http_client.rs b/ushadow/launcher/src-tauri/src/commands/http_client.rs new file mode 100644 index 00000000..2fac6d8b --- /dev/null +++ b/ushadow/launcher/src-tauri/src/commands/http_client.rs @@ -0,0 +1,81 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Serialize, Deserialize)] +pub struct HttpResponse { + pub status: u16, + pub body: String, + pub headers: HashMap, +} + +/// Make an HTTP request from Rust (bypasses CORS) +#[tauri::command] +pub async fn http_request( + url: String, + method: String, + headers: Option>, + body: Option, +) -> Result { + eprintln!("[HTTP] Making {} request to: {}", method, url); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let mut request: reqwest::RequestBuilder = match method.to_uppercase().as_str() { + "GET" => client.get(&url), + "POST" => client.post(&url), + "PUT" => client.put(&url), + "DELETE" => client.delete(&url), + "PATCH" => client.patch(&url), + _ => return Err(format!("Unsupported HTTP method: {}", method)), + }; + + // Add headers + if let Some(headers) = headers { + for (key, value) in headers { + request = request.header(key, value); + } + } + + // Add body for POST/PUT/PATCH + if let Some(body_content) = body { + eprintln!("[HTTP] Request body: {}", body_content); + request = request.body(body_content); + } + + // Send request + eprintln!("[HTTP] Sending request..."); + let response = request + .send() + .await + .map_err(|e| { + eprintln!("[HTTP] Request failed: {}", e); + format!("HTTP request failed: {}", e) + })?; + + eprintln!("[HTTP] Response status: {}", response.status()); + + let status = response.status().as_u16(); + + // Extract headers + let mut response_headers = HashMap::new(); + for (key, value) in response.headers() { + if let Ok(value_str) = value.to_str() { + response_headers.insert(key.to_string(), value_str.to_string()); + } + } + + // Get body + let body = response + .text() + .await + .map_err(|e| format!("Failed to read response body: {}", e))?; + + Ok(HttpResponse { + status, + body, + headers: response_headers, + }) +} diff --git a/ushadow/launcher/src-tauri/src/commands/kanban.rs b/ushadow/launcher/src-tauri/src/commands/kanban.rs new file mode 100644 index 00000000..1c3b53ce --- /dev/null +++ b/ushadow/launcher/src-tauri/src/commands/kanban.rs @@ -0,0 +1,1104 @@ +use crate::models::{Epic, Ticket, TicketPriority, TicketStatus}; +use super::worktree::create_worktree_with_workmux; +use super::utils::shell_command; +use std::path::PathBuf; +use std::fs; +use serde::{Deserialize, Serialize}; +use tauri::api::path::data_dir; + +/// Write a `.claude/settings.local.json` into a worktree so Claude Code hooks +/// fire and update kanban ticket status automatically. +/// Skips silently if the file already exists (preserves user customisations). +fn setup_claude_hooks(worktree_path: &str) { + let settings_path = format!("{}/.claude/settings.local.json", worktree_path); + if std::path::Path::new(&settings_path).exists() { + eprintln!("[setup_claude_hooks] settings.local.json already exists, skipping"); + return; + } + if let Err(e) = std::fs::create_dir_all(format!("{}/.claude", worktree_path)) { + eprintln!("[setup_claude_hooks] Failed to create .claude dir: {}", e); + return; + } + let content = r#"{ + "skipDangerousModePermissionPrompt": true, + "hooks": { + "SessionStart": [{"hooks": [{"type": "command", "async": true, + "command": "command -v kanban-cli >/dev/null 2>&1 && kanban-cli move-to-progress \"$(pwd)\" 2>/dev/null; exit 0"}]}], + "UserPromptSubmit": [{"hooks": [{"type": "command", "async": true, + "command": "command -v kanban-cli >/dev/null 2>&1 && kanban-cli move-to-progress \"$(pwd)\" 2>/dev/null; exit 0"}]}], + "Notification": [{"matcher": "idle_prompt", "hooks": [{"type": "command", "async": true, + "command": "command -v kanban-cli >/dev/null 2>&1 && kanban-cli move-to-review \"$(pwd)\" 2>/dev/null; exit 0"}]}], + "Stop": [{"hooks": [{"type": "command", "async": true, + "command": "command -v kanban-cli >/dev/null 2>&1 && kanban-cli move-to-review \"$(pwd)\" 2>/dev/null; exit 0"}]}] + } +} +"#; + match std::fs::write(&settings_path, content) { + Ok(_) => eprintln!("[setup_claude_hooks] ✓ Wrote {}", settings_path), + Err(e) => eprintln!("[setup_claude_hooks] Failed to write {}: {}", settings_path, e), + } +} + +/// Request to create a ticket with worktree and tmux +#[derive(Debug, Deserialize)] +pub struct CreateTicketWorktreeRequest { + pub ticket_id: String, + pub ticket_title: String, + pub project_root: String, + pub environment_name: String, // Simple name for worktree directory (e.g., "staging") + pub branch_name: Option, // If None, will be generated from ticket_id + pub base_branch: Option, // Default to "main" + pub epic_branch: Option, // If part of epic with shared branch +} + +/// Result of creating a ticket worktree +#[derive(Debug, Serialize)] +pub struct CreateTicketWorktreeResult { + pub worktree_path: String, + pub branch_name: String, + pub tmux_window_name: String, + pub tmux_session_name: String, +} + +/// Create a worktree and tmux window for a kanban ticket +/// +/// This command handles two scenarios: +/// 1. Ticket has its own branch (epic_branch is None) +/// 2. Ticket shares a branch with epic (epic_branch is Some) +#[tauri::command] +pub async fn create_ticket_worktree( + request: CreateTicketWorktreeRequest, +) -> Result { + eprintln!("[create_ticket_worktree] Creating worktree for ticket: {}", request.ticket_title); + + // Determine branch to use + let branch_name = if let Some(epic_branch) = request.epic_branch { + // Use epic's shared branch + eprintln!("[create_ticket_worktree] Using epic's shared branch: {}", epic_branch); + epic_branch + } else if let Some(branch_name) = request.branch_name { + // Use provided branch name + branch_name + } else { + // Generate branch name from ticket ID + format!("ticket-{}", request.ticket_id) + }; + + // Session = ush-{env_name}, window = ushadow-{env_name}. + // workmux identifies windows by the worktree directory basename, so the window name must + // be {window_prefix}{dir_basename} (env_name = the directory name, not the branch name). + let tmux_window_name = format!("ushadow-{}", request.environment_name); + let tmux_session_name = format!("ush-{}", request.environment_name); + + // Create worktree with tmux integration (custom_window_name=None — derived inside) + let worktree_info = create_worktree_with_workmux( + request.project_root.clone(), + request.environment_name.clone(), // name: worktree directory name + Some(branch_name.clone()), // branch_name: git branch (window derived from this) + request.base_branch.clone(), + Some(false), + None, // custom_window_name: ignored, derived from branch_name inside + ).await?; + + eprintln!("[create_ticket_worktree] ✓ Worktree created at: {}", worktree_info.path); + eprintln!("[create_ticket_worktree] ✓ Session: {}, Window: {}", tmux_session_name, tmux_window_name); + + setup_claude_hooks(&worktree_info.path); + + Ok(CreateTicketWorktreeResult { + worktree_path: worktree_info.path, + branch_name, + tmux_window_name, + tmux_session_name, + }) +} + +/// Attach an existing ticket to an existing worktree (for epic-shared branches) +#[tauri::command] +pub async fn attach_ticket_to_worktree( + ticket_id: String, + worktree_path: String, + branch_name: String, +) -> Result { + eprintln!("[attach_ticket_to_worktree] Attaching ticket {} to existing worktree: {}", ticket_id, worktree_path); + + // Verify worktree exists + let path_buf = PathBuf::from(&worktree_path); + if !path_buf.exists() { + eprintln!("[attach_ticket_to_worktree] ERROR: Worktree path does not exist: {}", worktree_path); + return Err(format!("Worktree path does not exist: {}", worktree_path)); + } + + // Derive env_name from the last component of the worktree path. + // e.g. "/repos/worktrees/ushadow/beige" → "beige" + let env_name = std::path::Path::new(&worktree_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("ushadow") + .to_string(); + + // Session = ush-{env}, window = ushadow-{env_name} (workmux uses dir basename as handle) + let tmux_session_name = format!("ush-{}", env_name); + let tmux_window_name = format!("ushadow-{}", env_name); + + // Ensure tmux server is running + shell_command("tmux start-server") + .output() + .map_err(|e| format!("Failed to start tmux server: {}", e))?; + + // Ensure the session exists + let session_exists = shell_command(&format!("tmux has-session -t {}", tmux_session_name)) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if !session_exists { + eprintln!("[attach_ticket_to_worktree] Creating session '{}'", tmux_session_name); + shell_command(&format!( + "tmux new-session -d -s {} -c '{}' -n '{}'", + tmux_session_name, worktree_path, tmux_window_name + )) + .output() + .map_err(|e| format!("Failed to create tmux session: {}", e))?; + } else { + // Session exists — add window if not already there + let window_exists = shell_command(&format!( + "tmux list-windows -t {} -F '#{{window_name}}' 2>/dev/null | grep -Fx '{}'", + tmux_session_name, tmux_window_name + )) + .output() + .map(|o| o.status.success() && !String::from_utf8_lossy(&o.stdout).trim().is_empty()) + .unwrap_or(false); + + if !window_exists { + eprintln!("[attach_ticket_to_worktree] Adding window '{}' to session '{}'", tmux_window_name, tmux_session_name); + shell_command(&format!( + "tmux new-window -t {} -n '{}' -c '{}'", + tmux_session_name, tmux_window_name, worktree_path + )) + .output() + .map_err(|e| format!("Failed to create tmux window: {}", e))?; + } else { + eprintln!("[attach_ticket_to_worktree] ✓ Window '{}' already exists", tmux_window_name); + } + } + + // Get the actual current branch from the worktree (more reliable than the passed-in value) + let actual_branch = shell_command(&format!("cd '{}' && git branch --show-current", worktree_path)) + .output() + .ok() + .and_then(|output| { + if output.status.success() { + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !branch.is_empty() { Some(branch) } else { None } + } else { + None + } + }) + .unwrap_or(branch_name); + + eprintln!("[attach_ticket_to_worktree] ✓ session='{}' window='{}' branch='{}'", + tmux_session_name, tmux_window_name, actual_branch); + + setup_claude_hooks(&worktree_path); + + Ok(CreateTicketWorktreeResult { + worktree_path, + branch_name: actual_branch, + tmux_window_name, + tmux_session_name, + }) +} + +/// List all tickets associated with a specific tmux window +/// Returns ticket IDs that are using this tmux window +#[tauri::command] +pub async fn get_tickets_for_tmux_window( + window_name: String, +) -> Result, String> { + // This will need to query the backend API + // For now, return empty list as placeholder + eprintln!("[get_tickets_for_tmux_window] Getting tickets for window: {}", window_name); + Ok(vec![]) +} + +/// Get tmux window information for a ticket +#[tauri::command] +pub async fn get_ticket_tmux_info( + ticket_id: String, +) -> Result, String> { + // This will need to query the backend API to get ticket's tmux details + // For now, return None as placeholder + eprintln!("[get_ticket_tmux_info] Getting tmux info for ticket: {}", ticket_id); + Ok(None) +} + +// ============================================================================ +// Local Ticket & Epic Storage (SQLite-based) +// ============================================================================ + +use rusqlite::{Connection, params}; + +/// Get the path to the SQLite database +fn get_db_path() -> Result { + let data_dir = data_dir().ok_or("Failed to get data directory")?; + let launcher_dir = data_dir.join("com.ushadow.launcher"); + + // Create directory if it doesn't exist + if !launcher_dir.exists() { + fs::create_dir_all(&launcher_dir) + .map_err(|e| format!("Failed to create launcher data directory: {}", e))?; + } + + Ok(launcher_dir.join("kanban.db")) +} + +/// Get a database connection and ensure schema is initialized +fn get_db_connection() -> Result { + let db_path = get_db_path()?; + let conn = Connection::open(&db_path) + .map_err(|e| format!("Failed to open database: {}", e))?; + + // Create tables if they don't exist + conn.execute( + "CREATE TABLE IF NOT EXISTS epics ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + color TEXT NOT NULL, + branch_name TEXT, + base_branch TEXT NOT NULL, + project_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + [], + ).map_err(|e| format!("Failed to create epics table: {}", e))?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS tickets ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL, + priority TEXT NOT NULL, + epic_id TEXT, + tags TEXT NOT NULL, + color TEXT, + tmux_window_name TEXT, + tmux_session_name TEXT, + branch_name TEXT, + worktree_path TEXT, + environment_name TEXT, + project_id TEXT, + assigned_to TEXT, + \"order\" INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (epic_id) REFERENCES epics (id) ON DELETE SET NULL + )", + [], + ).map_err(|e| format!("Failed to create tickets table: {}", e))?; + + // Create indexes for common queries + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status)", + [], + ).map_err(|e| format!("Failed to create index: {}", e))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_tickets_project ON tickets(project_id)", + [], + ).map_err(|e| format!("Failed to create index: {}", e))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_epics_project ON epics(project_id)", + [], + ).map_err(|e| format!("Failed to create index: {}", e))?; + + Ok(conn) +} + +/// Get all tickets, optionally filtered by project +#[tauri::command] +pub async fn get_tickets(project_id: Option) -> Result, String> { + let conn = get_db_connection()?; + + // Build query based on filter + let query = if project_id.is_some() { + "SELECT * FROM tickets WHERE project_id = ? ORDER BY \"order\"" + } else { + "SELECT * FROM tickets ORDER BY \"order\"" + }; + + let mut stmt = conn.prepare(query) + .map_err(|e| format!("Failed to prepare statement: {}", e))?; + + // Helper function to map row to Ticket + let map_row = |row: &rusqlite::Row| -> Result { + Ok(Ticket { + id: row.get(0)?, + title: row.get(1)?, + description: row.get(2)?, + status: match row.get::<_, String>(3)?.as_str() { + "backlog" => TicketStatus::Backlog, + "todo" => TicketStatus::Todo, + "in_progress" => TicketStatus::InProgress, + "in_review" => TicketStatus::InReview, + "done" => TicketStatus::Done, + "archived" => TicketStatus::Archived, + _ => TicketStatus::Backlog, + }, + priority: match row.get::<_, String>(4)?.as_str() { + "low" => TicketPriority::Low, + "medium" => TicketPriority::Medium, + "high" => TicketPriority::High, + "urgent" => TicketPriority::Urgent, + _ => TicketPriority::Medium, + }, + epic_id: row.get(5)?, + tags: serde_json::from_str(&row.get::<_, String>(6)?).unwrap_or_default(), + color: row.get(7)?, + tmux_window_name: row.get(8)?, + tmux_session_name: row.get(9)?, + branch_name: row.get(10)?, + worktree_path: row.get(11)?, + environment_name: row.get(12)?, + project_id: row.get(13)?, + assigned_to: row.get(14)?, + order: row.get(15)?, + created_at: row.get(16)?, + updated_at: row.get(17)?, + }) + }; + + // Execute query with or without parameter + let tickets: Vec = if let Some(pid) = project_id { + stmt.query_map([pid], map_row) + .map_err(|e| format!("Failed to query tickets: {}", e))? + .filter_map(|r| r.ok()) + .collect() + } else { + stmt.query_map([], map_row) + .map_err(|e| format!("Failed to query tickets: {}", e))? + .filter_map(|r| r.ok()) + .collect() + }; + + Ok(tickets) +} + +/// Get all epics, optionally filtered by project +#[tauri::command] +pub async fn get_epics(project_id: Option) -> Result, String> { + let conn = get_db_connection()?; + + // Build query based on filter + let query = if project_id.is_some() { + "SELECT * FROM epics WHERE project_id = ? ORDER BY created_at DESC" + } else { + "SELECT * FROM epics ORDER BY created_at DESC" + }; + + let mut stmt = conn.prepare(query) + .map_err(|e| format!("Failed to prepare statement: {}", e))?; + + // Helper function to map row to Epic + let map_row = |row: &rusqlite::Row| -> Result { + Ok(Epic { + id: row.get(0)?, + title: row.get(1)?, + description: row.get(2)?, + color: row.get(3)?, + branch_name: row.get(4)?, + base_branch: row.get(5)?, + project_id: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + }) + }; + + // Execute query with or without parameter + let epics: Vec = if let Some(pid) = project_id { + stmt.query_map([pid], map_row) + .map_err(|e| format!("Failed to query epics: {}", e))? + .filter_map(|r| r.ok()) + .collect() + } else { + stmt.query_map([], map_row) + .map_err(|e| format!("Failed to query epics: {}", e))? + .filter_map(|r| r.ok()) + .collect() + }; + + Ok(epics) +} + +/// Create a new ticket +#[tauri::command] +pub async fn create_ticket( + title: String, + description: Option, + priority: String, + epic_id: Option, + tags: Vec, + environment_name: Option, + project_id: Option, +) -> Result { + let conn = get_db_connection()?; + + // Parse priority + let priority_enum = match priority.as_str() { + "low" => TicketPriority::Low, + "medium" => TicketPriority::Medium, + "high" => TicketPriority::High, + "urgent" => TicketPriority::Urgent, + _ => TicketPriority::Medium, + }; + + // Generate sequential ticket ID (e.g., ush-1, ush-2, etc.) + let prefix = "ush"; + let next_number = get_next_ticket_number(&conn, prefix)?; + let id = format!("{}-{}", prefix, next_number); + + // Get current timestamp + let now = chrono::Utc::now().to_rfc3339(); + + // Calculate order (highest + 1 for backlog status) + let max_order: i32 = conn.query_row( + "SELECT COALESCE(MAX(\"order\"), -1) FROM tickets WHERE status = 'backlog'", + [], + |row| row.get(0), + ).unwrap_or(-1); + + let order = max_order + 1; + + // Serialize tags to JSON + let tags_json = serde_json::to_string(&tags) + .map_err(|e| format!("Failed to serialize tags: {}", e))?; + + // Insert ticket into database + conn.execute( + "INSERT INTO tickets (id, title, description, status, priority, epic_id, tags, color, tmux_window_name, tmux_session_name, branch_name, worktree_path, environment_name, project_id, assigned_to, \"order\", created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", + params![ + &id, + &title, + &description, + "backlog", + &priority, + &epic_id, + &tags_json, + None::, // color + None::, // tmux_window_name + None::, // tmux_session_name + None::, // branch_name + None::, // worktree_path + &environment_name, + &project_id, + None::, // assigned_to + order, + &now, + &now, + ], + ).map_err(|e| format!("Failed to insert ticket: {}", e))?; + + Ok(Ticket { + id, + title, + description, + status: TicketStatus::Backlog, + priority: priority_enum, + epic_id, + tags, + color: None, + tmux_window_name: None, + tmux_session_name: None, + branch_name: None, + worktree_path: None, + environment_name, + project_id, + assigned_to: None, + order, + created_at: now.clone(), + updated_at: now, + }) +} + +/// Update a ticket +#[tauri::command] +pub async fn update_ticket( + id: String, + title: Option, + description: Option, + status: Option, + priority: Option, + epic_id: Option, + tags: Option>, + order: Option, + worktree_path: Option, + branch_name: Option, + tmux_window_name: Option, + tmux_session_name: Option, + environment_name: Option, +) -> Result { + let conn = get_db_connection()?; + + // First, get the current ticket to return updated version + let mut stmt = conn.prepare("SELECT * FROM tickets WHERE id = ?") + .map_err(|e| format!("Failed to prepare statement: {}", e))?; + + let mut ticket = stmt.query_row([&id], |row| { + Ok(Ticket { + id: row.get(0)?, + title: row.get(1)?, + description: row.get(2)?, + status: match row.get::<_, String>(3)?.as_str() { + "backlog" => TicketStatus::Backlog, + "todo" => TicketStatus::Todo, + "in_progress" => TicketStatus::InProgress, + "in_review" => TicketStatus::InReview, + "done" => TicketStatus::Done, + "archived" => TicketStatus::Archived, + _ => TicketStatus::Backlog, + }, + priority: match row.get::<_, String>(4)?.as_str() { + "low" => TicketPriority::Low, + "medium" => TicketPriority::Medium, + "high" => TicketPriority::High, + "urgent" => TicketPriority::Urgent, + _ => TicketPriority::Medium, + }, + epic_id: row.get(5)?, + tags: serde_json::from_str(&row.get::<_, String>(6)?).unwrap_or_default(), + color: row.get(7)?, + tmux_window_name: row.get(8)?, + tmux_session_name: row.get(9)?, + branch_name: row.get(10)?, + worktree_path: row.get(11)?, + environment_name: row.get(12)?, + project_id: row.get(13)?, + assigned_to: row.get(14)?, + order: row.get(15)?, + created_at: row.get(16)?, + updated_at: row.get(17)?, + }) + }).map_err(|e| format!("Ticket not found: {}", e))?; + + // Update fields in memory + if let Some(t) = title { + ticket.title = t; + } + if let Some(d) = description { + ticket.description = Some(d); + } + if let Some(s) = status { + ticket.status = match s.as_str() { + "backlog" => TicketStatus::Backlog, + "todo" => TicketStatus::Todo, + "in_progress" => TicketStatus::InProgress, + "in_review" => TicketStatus::InReview, + "done" => TicketStatus::Done, + "archived" => TicketStatus::Archived, + _ => ticket.status, + }; + } + if let Some(p) = priority { + ticket.priority = match p.as_str() { + "low" => TicketPriority::Low, + "medium" => TicketPriority::Medium, + "high" => TicketPriority::High, + "urgent" => TicketPriority::Urgent, + _ => ticket.priority, + }; + } + if let Some(e) = epic_id { + ticket.epic_id = Some(e); + } + if let Some(t) = tags { + ticket.tags = t; + } + if let Some(o) = order { + ticket.order = o; + } + // Handle worktree_path: empty string means clear, non-empty means set + if let Some(wp) = worktree_path { + ticket.worktree_path = if wp.is_empty() { None } else { Some(wp) }; + } + // Handle branch_name: empty string means clear, non-empty means set + if let Some(bn) = branch_name { + ticket.branch_name = if bn.is_empty() { None } else { Some(bn) }; + } + // Handle tmux_window_name: empty string means clear, non-empty means set + if let Some(twn) = tmux_window_name { + ticket.tmux_window_name = if twn.is_empty() { None } else { Some(twn) }; + } + // Handle tmux_session_name: empty string means clear, non-empty means set + if let Some(tsn) = tmux_session_name { + ticket.tmux_session_name = if tsn.is_empty() { None } else { Some(tsn) }; + } + // Handle environment_name: empty string means clear, non-empty means set + if let Some(en) = environment_name { + ticket.environment_name = if en.is_empty() { None } else { Some(en) }; + } + + ticket.updated_at = chrono::Utc::now().to_rfc3339(); + + // Serialize tags to JSON + let tags_json = serde_json::to_string(&ticket.tags) + .map_err(|e| format!("Failed to serialize tags: {}", e))?; + + // Convert status and priority to strings + let status_str = match ticket.status { + TicketStatus::Backlog => "backlog", + TicketStatus::Todo => "todo", + TicketStatus::InProgress => "in_progress", + TicketStatus::InReview => "in_review", + TicketStatus::Done => "done", + TicketStatus::Archived => "archived", + }; + + let priority_str = match ticket.priority { + TicketPriority::Low => "low", + TicketPriority::Medium => "medium", + TicketPriority::High => "high", + TicketPriority::Urgent => "urgent", + }; + + // Update in database + conn.execute( + "UPDATE tickets SET title = ?1, description = ?2, status = ?3, priority = ?4, epic_id = ?5, tags = ?6, \"order\" = ?7, worktree_path = ?8, branch_name = ?9, tmux_window_name = ?10, tmux_session_name = ?11, environment_name = ?12, updated_at = ?13 WHERE id = ?14", + params![ + &ticket.title, + &ticket.description, + status_str, + priority_str, + &ticket.epic_id, + &tags_json, + ticket.order, + &ticket.worktree_path, + &ticket.branch_name, + &ticket.tmux_window_name, + &ticket.tmux_session_name, + &ticket.environment_name, + &ticket.updated_at, + &id, + ], + ).map_err(|e| format!("Failed to update ticket: {}", e))?; + + Ok(ticket) +} + +/// Delete a ticket +#[tauri::command] +pub async fn delete_ticket(id: String) -> Result<(), String> { + let conn = get_db_connection()?; + + conn.execute("DELETE FROM tickets WHERE id = ?", params![&id]) + .map_err(|e| format!("Failed to delete ticket: {}", e))?; + + Ok(()) +} + +/// Create a new epic +#[tauri::command] +pub async fn create_epic( + title: String, + description: Option, + color: String, + base_branch: String, + branch_name: Option, + project_id: Option, +) -> Result { + let conn = get_db_connection()?; + + let id = format!("epic-{}", uuid::Uuid::new_v4()); + let now = chrono::Utc::now().to_rfc3339(); + + // Insert epic into database + conn.execute( + "INSERT INTO epics (id, title, description, color, branch_name, base_branch, project_id, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + &id, + &title, + &description, + &color, + &branch_name, + &base_branch, + &project_id, + &now, + &now, + ], + ).map_err(|e| format!("Failed to insert epic: {}", e))?; + + Ok(Epic { + id, + title, + description, + color, + branch_name, + base_branch, + project_id, + created_at: now.clone(), + updated_at: now, + }) +} + +/// Update an epic +#[tauri::command] +pub async fn update_epic( + id: String, + title: Option, + description: Option, + color: Option, + branch_name: Option, +) -> Result { + let conn = get_db_connection()?; + + // First, get the current epic to return updated version + let mut stmt = conn.prepare("SELECT * FROM epics WHERE id = ?") + .map_err(|e| format!("Failed to prepare statement: {}", e))?; + + let mut epic = stmt.query_row([&id], |row| { + Ok(Epic { + id: row.get(0)?, + title: row.get(1)?, + description: row.get(2)?, + color: row.get(3)?, + branch_name: row.get(4)?, + base_branch: row.get(5)?, + project_id: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + }) + }).map_err(|e| format!("Epic not found: {}", e))?; + + // Update fields in memory + if let Some(t) = title { + epic.title = t; + } + if let Some(d) = description { + epic.description = Some(d); + } + if let Some(c) = color { + epic.color = c; + } + if let Some(b) = branch_name { + epic.branch_name = Some(b); + } + + epic.updated_at = chrono::Utc::now().to_rfc3339(); + + // Update in database + conn.execute( + "UPDATE epics SET title = ?1, description = ?2, color = ?3, branch_name = ?4, updated_at = ?5 WHERE id = ?6", + params![ + &epic.title, + &epic.description, + &epic.color, + &epic.branch_name, + &epic.updated_at, + &id, + ], + ).map_err(|e| format!("Failed to update epic: {}", e))?; + + Ok(epic) +} + +/// Delete an epic +#[tauri::command] +pub async fn delete_epic(id: String) -> Result<(), String> { + let conn = get_db_connection()?; + + conn.execute("DELETE FROM epics WHERE id = ?", params![&id]) + .map_err(|e| format!("Failed to delete epic: {}", e))?; + + Ok(()) +} + +/// Start a coding agent in the tmux window for a ticket +#[tauri::command] +pub async fn start_coding_agent_for_ticket( + ticket_id: String, + tmux_window_name: String, + tmux_session_name: String, + worktree_path: String, +) -> Result<(), String> { + use super::settings::load_launcher_settings; + + eprintln!("[start_coding_agent_for_ticket] Starting agent for ticket: {}", ticket_id); + eprintln!("[start_coding_agent_for_ticket] Tmux window: {}, session: {}", tmux_window_name, tmux_session_name); + eprintln!("[start_coding_agent_for_ticket] Worktree path: {}", worktree_path); + + // Load settings to get coding agent configuration + let settings = load_launcher_settings().await?; + + if !settings.coding_agent.auto_start { + eprintln!("[start_coding_agent_for_ticket] Auto-start is disabled, skipping"); + return Ok(()); + } + + // Get ticket details + let ticket = get_ticket_by_id(&ticket_id)?; + + // Automatically move ticket to in_progress when starting agent + eprintln!("[start_coding_agent_for_ticket] Moving ticket to in_progress..."); + if let Some(branch_name) = &ticket.branch_name { + // Use kanban-cli to move to in_progress + let status_update = shell_command(&format!("kanban-cli move-to-progress \"{}\"", branch_name)) + .output(); + + match status_update { + Ok(output) if output.status.success() => { + eprintln!("[start_coding_agent_for_ticket] ✓ Ticket moved to in_progress"); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("[start_coding_agent_for_ticket] Warning: Failed to update status: {}", stderr); + } + Err(e) => { + eprintln!("[start_coding_agent_for_ticket] Warning: Failed to run kanban-cli: {}", e); + } + } + } + + eprintln!("[start_coding_agent_for_ticket] Found ticket: {}", ticket.title); + + // Build the agent prompt with ticket context + let prompt = format!( + "You are working on the following ticket:\n\nTitle: {}\n\nDescription: {}\n\nPlease help implement this feature.", + ticket.title, + ticket.description.as_ref().unwrap_or(&"No description".to_string()) + ); + + // Build the base agent command from settings + let base_agent_command = if settings.coding_agent.args.is_empty() { + settings.coding_agent.command.clone() + } else { + format!("{} {}", settings.coding_agent.command, settings.coding_agent.args.join(" ")) + }; + + // Always enable agent teams so any running instance can become a lead + // and split panes can coordinate when multiple tickets land in the same worktree. + let agent_command = format!("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 {}", base_agent_command); + + // Verify the tmux window exists + let windows_output = shell_command(&format!( + "tmux list-windows -t {} -F '#{{window_name}}'", + tmux_session_name + )) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) + .map_err(|e| format!("Failed to check tmux windows: {}", e))?; + + if !windows_output.contains(&tmux_window_name) { + return Err(format!("Tmux window '{}' not found in session '{}'", tmux_window_name, tmux_session_name)); + } + + // Check if a Claude session is already running in this window. + let current_command = shell_command(&format!( + "tmux display-message -t {}:{} -p '#{{pane_current_command}}'", + tmux_session_name, tmux_window_name + )) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default(); + + let is_shell = matches!(current_command.as_str(), "zsh" | "bash" | "sh" | "fish" | ""); + + if !is_shell { + // An agent (likely Claude) is already running — it becomes the team lead. + // Ask it to spawn a teammate for the new ticket rather than starting a second + // Claude instance ourselves. The lead's agent teams support handles pane splitting + // and task coordination natively. + eprintln!( + "[start_coding_agent_for_ticket] Lead agent '{}' running — delegating new ticket via agent teams", + current_command + ); + + let spawn_request = format!( + "A new ticket has been assigned to this workspace. \ + Please spawn a teammate using agent teams to work on it in a split pane. \ + Title: {} — Description: {}", + ticket.title, + ticket.description.as_ref().unwrap_or(&"No description".to_string()) + ); + + // Write the teammate request to a temp script to avoid tmux send-keys quoting issues + let script_key = format!("{}-{}-spawn", tmux_session_name, tmux_window_name).replace('/', "_"); + let temp_script = format!("/tmp/ushadow_spawn_{}.sh", script_key); + let ansi_escaped = spawn_request + .replace('\\', "\\\\") + .replace('\'', "\\'") + .replace('\n', "\\n"); + let script_content = format!("#!/bin/bash\ntmux send-keys -t {}:{} $'{}' Enter\n", + tmux_session_name, tmux_window_name, ansi_escaped); + if let Ok(()) = std::fs::write(&temp_script, &script_content) { + let _ = shell_command(&format!("chmod +x {} && bash {}", temp_script, temp_script)).output(); + } + + eprintln!("[start_coding_agent_for_ticket] ✓ Teammate request sent to lead"); + return Ok(()); + } + + // No agent running — write a temp script so multi-layer quoting (Rust → shell → tmux → shell) + // can't corrupt the prompt text. Uses bash $'...' ANSI-C quoting inside the file. + let script_key = format!("{}-{}", tmux_session_name, tmux_window_name).replace('/', "_"); + let temp_script = format!("/tmp/ushadow_agent_{}.sh", script_key); + + let ansi_escaped_prompt = prompt + .replace('\\', "\\\\") + .replace('\'', "\\'") + .replace('\n', "\\n"); + + let script_content = format!( + "#!/bin/bash\ncd '{}'\nexec {} $'{}'\n", + worktree_path.replace('\'', "'\\''"), + agent_command, + ansi_escaped_prompt + ); + + std::fs::write(&temp_script, &script_content) + .map_err(|e| format!("Failed to write agent start script: {}", e))?; + shell_command(&format!("chmod +x {}", temp_script)) + .output() + .map_err(|e| format!("Failed to chmod agent script: {}", e))?; + + eprintln!("[start_coding_agent_for_ticket] Starting agent via script: {}", temp_script); + + let start_agent = shell_command(&format!( + "tmux send-keys -t {}:{} 'bash {}' Enter", + tmux_session_name, tmux_window_name, temp_script + )) + .output() + .map_err(|e| format!("Failed to start agent: {}", e))?; + + if !start_agent.status.success() { + return Err(format!( + "Failed to start coding agent: {}", + String::from_utf8_lossy(&start_agent.stderr) + )); + } + + eprintln!("[start_coding_agent_for_ticket] ✓ Agent starting headlessly in tmux (no terminal needed)"); + Ok(()) +} + +/// Get the next ticket number for a given prefix +fn get_next_ticket_number(conn: &rusqlite::Connection, prefix: &str) -> Result { + // Query all ticket IDs that match the prefix pattern + let pattern = format!("{}-%%", prefix); + let mut stmt = conn.prepare("SELECT id FROM tickets WHERE id LIKE ?") + .map_err(|e| format!("Failed to prepare statement: {}", e))?; + + let ticket_ids = stmt.query_map([&pattern], |row| { + row.get::<_, String>(0) + }).map_err(|e| format!("Failed to query tickets: {}", e))?; + + // Find the highest number + let mut max_number = 0; + for id_result in ticket_ids { + if let Ok(id) = id_result { + // Extract number from "ush-123" format + if let Some(number_str) = id.strip_prefix(&format!("{}-", prefix)) { + if let Ok(number) = number_str.parse::() { + if number > max_number { + max_number = number; + } + } + } + } + } + + Ok(max_number + 1) +} + +/// Helper to get a ticket by ID (internal use) +pub fn get_ticket_by_worktree_path(worktree_path: &str) -> Option { + let conn = get_db_connection().ok()?; + let mut stmt = conn.prepare( + "SELECT * FROM tickets WHERE worktree_path = ? AND status != 'done' AND status != 'archived' ORDER BY updated_at DESC LIMIT 1" + ).ok()?; + + stmt.query_row([worktree_path], |row| { + Ok(Ticket { + id: row.get(0)?, + title: row.get(1)?, + description: row.get(2)?, + status: match row.get::<_, String>(3)?.as_str() { + "backlog" => TicketStatus::Backlog, + "todo" => TicketStatus::Todo, + "in_progress" => TicketStatus::InProgress, + "in_review" => TicketStatus::InReview, + "done" => TicketStatus::Done, + "archived" => TicketStatus::Archived, + _ => TicketStatus::Backlog, + }, + priority: match row.get::<_, String>(4)?.as_str() { + "low" => TicketPriority::Low, + "medium" => TicketPriority::Medium, + "high" => TicketPriority::High, + "urgent" => TicketPriority::Urgent, + _ => TicketPriority::Medium, + }, + epic_id: row.get(5)?, + tags: serde_json::from_str(&row.get::<_, String>(6)?).unwrap_or_default(), + color: row.get(7)?, + tmux_window_name: row.get(8)?, + tmux_session_name: row.get(9)?, + branch_name: row.get(10)?, + worktree_path: row.get(11)?, + environment_name: row.get(12)?, + project_id: row.get(13)?, + assigned_to: row.get(14)?, + order: row.get(15)?, + created_at: row.get(16)?, + updated_at: row.get(17)?, + }) + }).ok() +} + +fn get_ticket_by_id(id: &str) -> Result { + let conn = get_db_connection()?; + + let mut stmt = conn.prepare("SELECT * FROM tickets WHERE id = ?") + .map_err(|e| format!("Failed to prepare statement: {}", e))?; + + stmt.query_row([id], |row| { + Ok(Ticket { + id: row.get(0)?, + title: row.get(1)?, + description: row.get(2)?, + status: match row.get::<_, String>(3)?.as_str() { + "backlog" => TicketStatus::Backlog, + "todo" => TicketStatus::Todo, + "in_progress" => TicketStatus::InProgress, + "in_review" => TicketStatus::InReview, + "done" => TicketStatus::Done, + "archived" => TicketStatus::Archived, + _ => TicketStatus::Backlog, + }, + priority: match row.get::<_, String>(4)?.as_str() { + "low" => TicketPriority::Low, + "medium" => TicketPriority::Medium, + "high" => TicketPriority::High, + "urgent" => TicketPriority::Urgent, + _ => TicketPriority::Medium, + }, + epic_id: row.get(5)?, + tags: serde_json::from_str(&row.get::<_, String>(6)?).unwrap_or_default(), + color: row.get(7)?, + tmux_window_name: row.get(8)?, + tmux_session_name: row.get(9)?, + branch_name: row.get(10)?, + worktree_path: row.get(11)?, + environment_name: row.get(12)?, + project_id: row.get(13)?, + assigned_to: row.get(14)?, + order: row.get(15)?, + created_at: row.get(16)?, + updated_at: row.get(17)?, + }) + }).map_err(|e| format!("Ticket not found: {}", e)) +} diff --git a/ushadow/launcher/src-tauri/src/commands/mod.rs b/ushadow/launcher/src-tauri/src/commands/mod.rs index 6c49cd67..d19c4cf0 100644 --- a/ushadow/launcher/src-tauri/src/commands/mod.rs +++ b/ushadow/launcher/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ mod docker; mod discovery; +mod discovery_v2; mod prerequisites; mod prerequisites_config; mod repository; // Repository and Git operations @@ -10,11 +11,20 @@ mod settings; mod bundled; // Bundled resources locator pub mod worktree; pub mod platform; // Platform abstraction layer +mod claude_sessions; // Claude Code session monitoring +mod kanban; // Kanban ticket integration +mod oauth_server; // OAuth callback server for desktop auth +mod http_client; // HTTP client for CORS-free requests // Embedded terminal module (PTY-based) - DEPRECATED in favor of native terminal integration (iTerm2/Terminal.app/gnome-terminal) // pub mod terminal; +mod config_commands; +mod container_discovery; +mod port_utils; +mod env_scanner; pub use docker::*; pub use discovery::*; +pub use discovery_v2::*; pub use prerequisites::*; pub use prerequisites_config::*; pub use repository::*; // Export repository management functions @@ -22,4 +32,12 @@ pub use generic_installer::*; // Export generic installer functions pub use permissions::*; pub use settings::*; pub use worktree::*; +pub use claude_sessions::*; // Export Claude session monitoring functions +pub use kanban::*; // Export kanban ticket functions +pub use oauth_server::*; // Export OAuth server functions +pub use http_client::*; // Export HTTP client functions // pub use terminal::*; +pub use config_commands::*; +pub use container_discovery::*; +pub use port_utils::*; +pub use env_scanner::*; diff --git a/ushadow/launcher/src-tauri/src/commands/oauth_server.rs b/ushadow/launcher/src-tauri/src/commands/oauth_server.rs new file mode 100644 index 00000000..73afe69e --- /dev/null +++ b/ushadow/launcher/src-tauri/src/commands/oauth_server.rs @@ -0,0 +1,190 @@ +/// OAuth callback server for desktop authentication +/// +/// Implements the standard OAuth flow for desktop apps: +/// 1. Start temporary HTTP server on random port +/// 2. Register http://localhost:PORT/callback with Casdoor +/// 3. Open system browser for login +/// 4. Catch redirect, exchange code for tokens +/// 5. Shut down server + +use std::sync::{Arc, Mutex}; +use tokio::sync::oneshot; +use warp::Filter; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct OAuthCallbackParams { + pub code: String, + pub state: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct OAuthResult { + pub success: bool, + pub code: Option, + pub state: Option, + pub error: Option, +} + +/// Start OAuth callback server and return the port and callback URL +/// +/// This starts a temporary HTTP server that waits for the OAuth callback. +/// The server automatically shuts down after receiving the callback or timing out. +#[tauri::command] +pub async fn start_oauth_server() -> Result<(u16, String), String> { + use warp::Filter; + + // Find available port + let listener = std::net::TcpListener::bind("127.0.0.1:0") + .map_err(|e| format!("Failed to bind to port: {}", e))?; + let port = listener.local_addr() + .map_err(|e| format!("Failed to get local address: {}", e))? + .port(); + drop(listener); + + let callback_url = format!("http://localhost:{}/callback", port); + + println!("[OAuth] Started callback server on port {}", port); + println!("[OAuth] Callback URL: {}", callback_url); + + Ok((port, callback_url)) +} + +/// Wait for OAuth callback +/// +/// This blocks until the callback is received or times out (5 minutes). +/// Returns the authorization code and state from the callback. +#[tauri::command] +pub async fn wait_for_oauth_callback(port: u16) -> Result { + use std::time::Duration; + use tokio::time::timeout; + + let result = Arc::new(Mutex::new(None)); + let result_clone = result.clone(); + + // Create shutdown signal + let (tx, rx) = oneshot::channel::<()>(); + let tx = Arc::new(Mutex::new(Some(tx))); + + // Callback route handler + let callback_route = warp::path("callback") + .and(warp::query::()) + .map(move |params: OAuthCallbackParams| { + println!("[OAuth] Callback received: code={}, state={}", + params.code.chars().take(10).collect::(), + params.state.chars().take(10).collect::() + ); + + // Store result + { + let mut result = result_clone.lock().unwrap(); + *result = Some(OAuthResult { + success: true, + code: Some(params.code.clone()), + state: Some(params.state.clone()), + error: None, + }); + } + + // Trigger shutdown + if let Some(tx) = tx.lock().unwrap().take() { + let _ = tx.send(()); + } + + // Return success page + warp::reply::html( + r#" + + + + Login Successful + + + +
+
+

Login Successful!

+

You can close this window and return to the Ushadow Launcher.

+
+ + + + "# + ) + }); + + // Start server + let server = warp::serve(callback_route) + .bind_with_graceful_shutdown(([127, 0, 0, 1], port), async { + rx.await.ok(); + }); + + // Run server with timeout + let server_task = tokio::spawn(server.1); + + // Wait for callback or timeout (5 minutes) + match timeout(Duration::from_secs(300), async { + loop { + if result.lock().unwrap().is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }).await { + Ok(_) => { + // Got callback + let result = result.lock().unwrap().take().unwrap(); + println!("[OAuth] Callback processed successfully"); + + // Shut down server + server_task.abort(); + + Ok(result) + } + Err(_) => { + // Timeout + println!("[OAuth] Callback timeout (5 minutes)"); + server_task.abort(); + + Ok(OAuthResult { + success: false, + code: None, + state: None, + error: Some("Timeout waiting for login".to_string()), + }) + } + } +} diff --git a/ushadow/launcher/src-tauri/src/commands/port_utils.rs b/ushadow/launcher/src-tauri/src/commands/port_utils.rs new file mode 100644 index 00000000..0695b63b --- /dev/null +++ b/ushadow/launcher/src-tauri/src/commands/port_utils.rs @@ -0,0 +1,182 @@ +use super::utils::silent_command; + +/// Port pair for backend and frontend (webui) +#[derive(Debug, Clone)] +pub struct PortPair { + pub backend: u16, + pub frontend: u16, +} + +/// Find available ports by calling Python's validate_ports from setup_utils.py +/// This uses the existing port validation logic and maintains the 5000 port separation +pub fn find_available_ports( + project_root: &str, + preferred_backend_port: u16, +) -> Result { + // Frontend port is always backend - 5000 + let preferred_frontend_port = if preferred_backend_port >= 5000 { + preferred_backend_port - 5000 + } else { + return Err(format!( + "Backend port {} too low (must be >= 5000 to maintain frontend separation)", + preferred_backend_port + )); + }; + + // Call Python to check if these ports are available + // Using the same logic as setup/run.py + if are_ports_available(project_root, preferred_backend_port, preferred_frontend_port)? { + return Ok(PortPair { + backend: preferred_backend_port, + frontend: preferred_frontend_port, + }); + } + + // Ports not available, find alternatives by incrementing offset + // This mirrors the logic in setup/run.py:145-160 + let base_backend = 8000; + let base_frontend = 3000; + let initial_offset = preferred_backend_port - base_backend; + + for attempt in 1..=100 { + let new_offset = initial_offset + (attempt * 10); + let backend = base_backend + new_offset; + let frontend = base_frontend + new_offset; + + if are_ports_available(project_root, backend, frontend)? { + return Ok(PortPair { backend, frontend }); + } + } + + Err("Could not find available ports after 100 attempts".to_string()) +} + +/// Check if both backend and frontend ports are available +/// Uses native Rust implementation (faster than calling Python subprocess) +/// This mirrors the logic from setup/setup_utils.py::check_port_in_use +fn are_ports_available( + _project_root: &str, + backend_port: u16, + frontend_port: u16, +) -> Result { + Ok(is_port_available(backend_port) && is_port_available(frontend_port)) +} + +/// Check if a single port is available by attempting to bind to it +fn is_port_available(port: u16) -> bool { + use std::net::TcpListener; + + match TcpListener::bind(("127.0.0.1", port)) { + Ok(_) => true, // Port is available + Err(_) => false, // Port is in use + } +} + +/// Get the Tailscale tailnet name from the host machine +/// Returns the tailnet domain (e.g., "thestumonkey.github") +pub fn get_tailnet_name() -> Result { + let output = silent_command("tailscale") + .args(["status", "--json"]) + .output() + .map_err(|e| format!("Failed to run tailscale command: {}", e))?; + + if !output.status.success() { + return Err("Tailscale not running or not available".to_string()); + } + + let json_str = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(&json_str) + .map_err(|e| format!("Failed to parse tailscale JSON: {}", e))?; + + let tailnet = json["CurrentTailnet"]["Name"] + .as_str() + .ok_or("Could not find tailnet name in status")?; + + Ok(tailnet.to_string()) +} + +/// Generate Tailscale URL for an environment +/// Format: https://{env_name}.{tailnet} or https://{project}-{env_name}.{tailnet} +/// +/// # Arguments +/// * `env_name` - Environment name (e.g., "orange", "blue") +/// * `project_prefix` - Optional project prefix for multi-project setups (e.g., Some("ushadow")) +pub fn generate_tailscale_url( + env_name: &str, + project_prefix: Option<&str>, +) -> Result, String> { + // Get the tailnet name from the host + let tailnet = match get_tailnet_name() { + Ok(t) => t, + Err(_) => return Ok(None), // Tailscale not available, return None instead of error + }; + + // Build hostname: either "envname" or "project-envname" + let hostname = if let Some(prefix) = project_prefix { + format!("{}-{}", prefix, env_name) + } else { + env_name.to_string() + }; + + let url = format!("https://{}.{}", hostname, tailnet); + + Ok(Some(url)) +} + +/// Parse the host port from a Docker Compose port mapping string. +/// +/// Handles: +/// - `"5432:5432"` → `Some(5432)` +/// - `"5432"` → `Some(5432)` +/// - `"0.0.0.0:8080:8080"` → `Some(8080)` (explicit bind address) +/// - `"${VAR:-8081}:8080"` → `Some(8081)` (shell expression, extract default) +pub fn parse_compose_host_port(mapping: &str) -> Option { + // Split on the LAST ':' — container port is always the rightmost segment. + // This correctly handles shell expressions like "${VAR:-8081}:8080" which + // contain ':' internally. + let host_part = if let Some(last_colon) = mapping.rfind(':') { + &mapping[..last_colon] + } else { + // Bare port with no colon, e.g. "5432" + return mapping.trim().parse::().ok(); + }; + + // Handle shell expressions like "${VAR:-8081}" — extract the default value. + if host_part.contains("${") { + if let Some(default_start) = host_part.find(":-") { + let after = &host_part[default_start + 2..]; + return after.trim_end_matches('}').trim().parse::().ok(); + } + return None; + } + + // Handle "addr:host" bind-address prefix, e.g. "0.0.0.0:8080". + if let Some(inner_colon) = host_part.rfind(':') { + return host_part[inner_colon + 1..].parse::().ok(); + } + + host_part.trim().parse::().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_port_availability() { + // Test that port checking works + // Note: This test might be flaky if ports are actually in use + let available = is_port_available(65432); // Use high port unlikely to be used + assert!(available, "High port should be available"); + } + + #[test] + fn test_port_pair_separation() { + let pair = PortPair { + backend: 8000, + frontend: 3000, + }; + + assert_eq!(pair.backend - pair.frontend, 5000); + } +} diff --git a/ushadow/launcher/src-tauri/src/commands/repository.rs b/ushadow/launcher/src-tauri/src/commands/repository.rs index 06ed8e23..b059eb3d 100644 --- a/ushadow/launcher/src-tauri/src/commands/repository.rs +++ b/ushadow/launcher/src-tauri/src/commands/repository.rs @@ -138,8 +138,36 @@ pub async fn clone_ushadow_repo(target_dir: String, branch: Option) -> R if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - return Err(format!("Git clone failed: {}. stdout: {}", stderr, stdout)); + + // Git uses stderr for both progress ("Cloning into...") and real errors. + // Filter to only the fatal/error lines so we don't include progress noise. + let error_lines: Vec<&str> = stderr + .lines() + .filter(|l| l.starts_with("fatal:") || l.starts_with("error:") || l.starts_with("remote: error")) + .collect(); + + let git_error = if error_lines.is_empty() { + stderr.trim().to_string() + } else { + error_lines.join("\n") + }; + + // Provide actionable messages for common failure modes + return Err(if git_error.contains("Could not resolve host") || git_error.contains("resolve host") { + format!( + "Network error: Could not connect to GitHub. Please check your internet connection and try again.\n\nDetails: {}", + git_error + ) + } else if git_error.contains("Permission denied") || git_error.to_lowercase().contains("access denied") { + format!( + "Permission denied cloning to {}. Please check you have write access to that directory.\n\nDetails: {}", + target_dir, git_error + ) + } else if git_error.contains("already exists") { + format!("Target directory already exists and is not empty: {}", target_dir) + } else { + format!("Git clone failed: {}", git_error) + }); } // Verify the clone actually worked by checking for .git directory diff --git a/ushadow/launcher/src-tauri/src/commands/settings.rs b/ushadow/launcher/src-tauri/src/commands/settings.rs index ceb3f7c1..8cbe47e2 100644 --- a/ushadow/launcher/src-tauri/src/commands/settings.rs +++ b/ushadow/launcher/src-tauri/src/commands/settings.rs @@ -2,11 +2,36 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodingAgentConfig { + /// Name/type of the coding agent (e.g., "claude", "aider", "cursor") + pub agent_type: String, + /// Command to run the agent (e.g., "claude", "aider") + pub command: String, + /// Additional arguments to pass to the agent + pub args: Vec, + /// Whether to auto-start the agent when a ticket is assigned + pub auto_start: bool, +} + +impl Default for CodingAgentConfig { + fn default() -> Self { + Self { + agent_type: "claude".to_string(), + command: "claude".to_string(), + args: vec!["--dangerously-skip-permissions".to_string()], + auto_start: true, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LauncherSettings { pub default_admin_email: Option, pub default_admin_password: Option, pub default_admin_name: Option, + #[serde(default)] + pub coding_agent: CodingAgentConfig, } impl Default for LauncherSettings { @@ -15,6 +40,7 @@ impl Default for LauncherSettings { default_admin_email: None, default_admin_password: None, default_admin_name: Some("Administrator".to_string()), + coding_agent: CodingAgentConfig::default(), } } } diff --git a/ushadow/launcher/src-tauri/src/commands/worktree.rs b/ushadow/launcher/src-tauri/src/commands/worktree.rs index 17697af5..167ef3e1 100644 --- a/ushadow/launcher/src-tauri/src/commands/worktree.rs +++ b/ushadow/launcher/src-tauri/src/commands/worktree.rs @@ -1,4 +1,4 @@ -use crate::models::{WorktreeInfo, TmuxSessionInfo, TmuxWindowInfo, ClaudeStatus}; +use crate::models::{WorktreeInfo, TmuxSessionInfo, TmuxWindowInfo, ClaudeStatus, EnvironmentConflict}; use std::collections::HashMap; use std::path::PathBuf; use std::process::Command; @@ -32,6 +32,92 @@ pub fn get_colors_for_name(name: &str) -> (String, String) { (name.to_string(), name.to_string()) } +/// Delete a git branch (best effort - won't fail if branch doesn't exist) +fn delete_branch(main_repo: &str, branch_name: &str) { + eprintln!("[delete_branch] Attempting to delete branch '{}'", branch_name); + + // Try to delete the branch with -D (force delete) + let output = silent_command("git") + .args(["branch", "-D", branch_name]) + .current_dir(main_repo) + .output(); + + match output { + Ok(result) if result.status.success() => { + eprintln!("[delete_branch] ✓ Successfully deleted branch '{}'", branch_name); + } + Ok(result) => { + let stderr = String::from_utf8_lossy(&result.stderr); + // Don't error if branch doesn't exist + if !stderr.contains("not found") && !stderr.contains("does not exist") { + eprintln!("[delete_branch] Warning: Failed to delete branch '{}': {}", branch_name, stderr); + } else { + eprintln!("[delete_branch] Branch '{}' already deleted or doesn't exist", branch_name); + } + } + Err(e) => { + eprintln!("[delete_branch] Warning: Failed to run git branch -D: {}", e); + } + } +} + +/// Returns true if a given Git ref exists in the repo. +fn git_ref_exists(main_repo: &str, ref_name: &str) -> bool { + let output = silent_command("git") + .args(["show-ref", "--verify", "--quiet", ref_name]) + .current_dir(main_repo) + .output(); + + match output { + Ok(result) => result.status.success(), + Err(_) => false, + } +} + +/// Resolve a base branch string to a real ref (with origin prefix when needed), with fallback for non-main roots. +fn resolve_base_branch(main_repo: &str, provided_base: &str) -> String { + let provided = provided_base.trim(); + + if provided.contains('/') { + return provided.to_string(); + } + + // For "main", try origin/main first, then origin/master + if provided == "main" { + // Check if origin/main exists + if git_ref_exists(main_repo, "origin/main") { + return "origin/main".to_string(); + } + // Fallback to origin/master + if git_ref_exists(main_repo, "origin/master") { + return "origin/master".to_string(); + } + // Local fallbacks + if git_ref_exists(main_repo, "main") { + return "main".to_string(); + } + if git_ref_exists(main_repo, "master") { + return "master".to_string(); + } + } + + // For other branches, use the same logic + let candidates: Vec = match provided { + "dev" => vec!["origin/dev".into(), "origin/develop".into(), "dev".into(), "develop".into(), "origin/main".into(), "origin/master".into()], + "master" => vec!["origin/master".into(), "origin/main".into(), "master".into(), "main".into()], + _ => vec![format!("origin/{}", provided), provided.to_string()], + }; + + for candidate in candidates { + if git_ref_exists(main_repo, &candidate) { + return candidate; + } + } + + // If no candidate exists, fall back to remote origin ref style for the provided branch name. + format!("origin/{}", provided) +} + /// Check if a worktree exists for a given branch #[tauri::command] pub async fn check_worktree_exists(main_repo: String, branch: String) -> Result, String> { @@ -105,6 +191,32 @@ pub async fn check_worktree_exists(main_repo: String, branch: String) -> Result< Ok(None) } +/// Check if an environment with this name already exists and return conflict info +#[tauri::command] +pub async fn check_environment_conflict( + main_repo: String, + env_name: String, +) -> Result, String> { + let env_name = env_name.to_lowercase(); + + // Check if a worktree with this name exists + let worktrees = list_worktrees(main_repo.clone()).await?; + + if let Some(worktree) = worktrees.iter().find(|wt| wt.name == env_name) { + // Worktree exists - return conflict info + // Note: is_running will be set to false here, but the frontend can check + // the actual running status from its discovery data + return Ok(Some(EnvironmentConflict { + name: env_name, + current_branch: worktree.branch.clone(), + path: worktree.path.clone(), + is_running: false, // Frontend will populate this from discovery + })); + } + + Ok(None) +} + /// List all git worktrees in a repository #[tauri::command] pub async fn list_worktrees(main_repo: String) -> Result, String> { @@ -209,6 +321,23 @@ pub async fn list_git_branches(main_repo: String) -> Result, String> unique_branches.sort(); unique_branches.dedup(); + // Fallback: if branch list is empty, detect current HEAD branch + if unique_branches.is_empty() { + let head_output = silent_command("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(&main_repo) + .output(); + + if let Ok(head_result) = head_output { + if head_result.status.success() { + let current_branch = String::from_utf8_lossy(&head_result.stdout).trim().to_string(); + if !current_branch.is_empty() && current_branch != "HEAD" { + unique_branches.push(current_branch); + } + } + } + } + Ok(unique_branches) } @@ -218,6 +347,7 @@ pub async fn create_worktree( main_repo: String, worktrees_dir: String, name: String, + branch_name: Option, base_branch: Option, ) -> Result { // Force lowercase to avoid Docker Compose naming issues @@ -242,7 +372,7 @@ pub async fn create_worktree( } // Determine the desired branch name (also lowercase) - let desired_branch = base_branch.map(|b| b.to_lowercase()).unwrap_or_else(|| name.clone()); + let desired_branch = branch_name.map(|b| b.to_lowercase()).unwrap_or_else(|| name.clone()); // Check if git has this worktree registered or if the branch is in use let list_output = silent_command("git") @@ -348,6 +478,48 @@ pub async fn create_worktree( let branch_exists = check_output.status.success(); + // Check for branch naming conflicts (e.g., can't create test/foo if test exists, or vice versa) + if !branch_exists { + // Check if any part of the branch path conflicts with existing branches + let all_branches_output = silent_command("git") + .args(["for-each-ref", "--format=%(refname:short)", "refs/heads/"]) + .current_dir(&main_repo) + .output() + .map_err(|e| format!("Failed to list branches: {}", e))?; + + let all_branches = String::from_utf8_lossy(&all_branches_output.stdout); + + for existing_branch in all_branches.lines() { + // Check if desired_branch would conflict with existing_branch + // Conflict cases: + // 1. Want to create "test/foo" but "test" exists + // 2. Want to create "test" but "test/foo" exists + if desired_branch.starts_with(&format!("{}/", existing_branch)) { + return Err(format!( + "Cannot create branch '{}' because branch '{}' already exists. Git doesn't allow 'foo' and 'foo/bar' to both exist as branches.", + desired_branch, existing_branch + )); + } + if existing_branch.starts_with(&format!("{}/", desired_branch)) { + return Err(format!( + "Cannot create branch '{}' because branch '{}' already exists. Git doesn't allow 'foo' and 'foo/bar' to both exist as branches.", + desired_branch, existing_branch + )); + } + } + } + + // Before creating, clean up any locked/missing worktrees at this path + eprintln!("[create_worktree] Checking for locked/missing worktrees..."); + let _ = silent_command("git") + .args(["worktree", "unlock", worktree_path.to_str().unwrap()]) + .current_dir(&main_repo) + .output(); + let _ = silent_command("git") + .args(["worktree", "prune"]) + .current_dir(&main_repo) + .output(); + let (output, final_branch) = if branch_exists { // Branch exists - checkout directly into worktree let output = silent_command("git") @@ -357,24 +529,27 @@ pub async fn create_worktree( .map_err(|e| format!("Failed to create worktree: {}", e))?; (output, desired_branch) } else { - // Branch doesn't exist - create new branch from remote base branch - // Parse branch name to determine base branch (e.g., rouge/myfeature-dev -> origin/dev) + // Branch doesn't exist - create new branch from base branch let new_branch_name = desired_branch.clone(); - // Determine base from branch suffix (-dev or -main) - let base = if new_branch_name.ends_with("-dev") { - "origin/dev" + // Determine base branch to use + // Priority: 1) Provided base_branch parameter, 2) Derived from suffix, 3) Default to origin/main + let base = if let Some(ref provided_base) = base_branch { + let resolved = resolve_base_branch(&main_repo, provided_base); + eprintln!("[create_worktree] Resolved provided base '{}' to '{}'", provided_base, resolved); + resolved + } else if new_branch_name.ends_with("-dev") { + resolve_base_branch(&main_repo, "dev") } else if new_branch_name.ends_with("-main") { - "origin/main" + resolve_base_branch(&main_repo, "main") } else { - // Default to origin/main if no suffix - "origin/main" + resolve_base_branch(&main_repo, "main") }; eprintln!("[create_worktree] Creating new branch '{}' from '{}'", new_branch_name, base); let output = silent_command("git") - .args(["worktree", "add", "-b", &new_branch_name, worktree_path.to_str().unwrap(), base]) + .args(["worktree", "add", "-b", &new_branch_name, worktree_path.to_str().unwrap(), &base]) .current_dir(&main_repo) .output() .map_err(|e| format!("Failed to create worktree: {}", e))?; @@ -419,17 +594,9 @@ async fn open_in_vscode_impl(path: String, env_name: Option, with_tmux: path, name ); - let output = shell_command(&color_setup_cmd) - .output() - .map_err(|e| format!("Failed to setup VSCode colors: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - eprintln!("[open_in_vscode] Warning: Color setup failed: {}", stderr); - // Continue anyway - color setup is not critical - } else { - eprintln!("[open_in_vscode] VSCode colors configured successfully"); - } + // Fire-and-forget: color setup is non-critical, don't block VS Code launch + let _ = shell_command(&color_setup_cmd).spawn(); + eprintln!("[open_in_vscode] VSCode color setup dispatched (async)"); } // Open VS Code (don't wait for it to finish) @@ -441,7 +608,9 @@ async fn open_in_vscode_impl(path: String, env_name: Option, with_tmux: // If with_tmux is true, create a shell script that VS Code can run if with_tmux && env_name.is_some() { let env_name_lower = env_name.unwrap().to_lowercase(); - let window_name = format!("ushadow-{}", env_name_lower); + // Sanitize env_name by replacing slashes (tmux doesn't allow slashes in window names) + let sanitized_env_name = env_name_lower.replace('/', "-").replace('\\', "-"); + let window_name = format!("ushadow-{}", sanitized_env_name); eprintln!("[open_in_vscode] Creating tmux attach script for VS Code terminal"); @@ -530,10 +699,10 @@ set -g terminal-overrides 'xterm*:smcup@:rmcup@'\n\ eprintln!("[open_in_vscode] ERROR: Failed to create tmux window: {}", stderr); return Err(format!("Failed to create tmux window: {}", stderr)); } else { - eprintln!("[open_in_vscode] ✓ Created tmux window '{}'", window_name); + eprintln!("[open_in_vscode] [OK] Created tmux window '{}'", window_name); } } else { - eprintln!("[open_in_vscode] ✓ Tmux window '{}' already exists", window_name); + eprintln!("[open_in_vscode] [OK] Tmux window '{}' already exists", window_name); } // Create .vscode directory if it doesn't exist @@ -644,7 +813,12 @@ pub async fn remove_worktree(main_repo: String, name: String) -> Result<(), Stri .find(|wt| wt.name == name) .ok_or_else(|| format!("Worktree '{}' not found", name))?; - // Remove the worktree + eprintln!("[remove_worktree] Removing worktree at: {}", worktree.path); + + // Store branch name for deletion after worktree removal + let branch_name = worktree.branch.clone(); + + // Try to remove the worktree let output = silent_command("git") .args(["worktree", "remove", &worktree.path]) .current_dir(&main_repo) @@ -653,9 +827,64 @@ pub async fn remove_worktree(main_repo: String, name: String) -> Result<(), Stri if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); + + // If it contains modified/untracked files, use --force + if stderr.contains("modified or untracked files") || stderr.contains("use --force") { + eprintln!("[remove_worktree] Worktree has uncommitted changes, forcing removal..."); + + let force_output = silent_command("git") + .args(["worktree", "remove", "--force", &worktree.path]) + .current_dir(&main_repo) + .output() + .map_err(|e| format!("Failed to force remove worktree: {}", e))?; + + if force_output.status.success() { + eprintln!("[remove_worktree] ✓ Successfully force-removed worktree"); + // Delete the associated branch + delete_branch(&main_repo, &branch_name); + return Ok(()); + } else { + let force_stderr = String::from_utf8_lossy(&force_output.stderr); + return Err(format!("Failed to force remove worktree: {}", force_stderr)); + } + } + + // If it's locked or missing, try to unlock and prune + if stderr.contains("locked") || stderr.contains("missing") { + eprintln!("[remove_worktree] Worktree is locked/missing, attempting to unlock and prune..."); + + // Try to unlock + let _ = silent_command("git") + .args(["worktree", "unlock", &worktree.path]) + .current_dir(&main_repo) + .output(); + + // Try to prune + let prune_output = silent_command("git") + .args(["worktree", "prune"]) + .current_dir(&main_repo) + .output() + .map_err(|e| format!("Failed to prune worktrees: {}", e))?; + + if prune_output.status.success() { + eprintln!("[remove_worktree] ✓ Successfully pruned locked/missing worktree"); + // Delete the associated branch + delete_branch(&main_repo, &branch_name); + return Ok(()); + } else { + let prune_stderr = String::from_utf8_lossy(&prune_output.stderr); + return Err(format!("Failed to prune worktree: {}", prune_stderr)); + } + } + return Err(format!("Git command failed: {}", stderr)); } + eprintln!("[remove_worktree] ✓ Worktree removed successfully"); + + // Delete the associated branch + delete_branch(&main_repo, &branch_name); + Ok(()) } @@ -682,34 +911,35 @@ pub async fn delete_environment(main_repo: String, env_name: String) -> Result { - messages.push(format!("✓ Stopped containers for '{}'", env_name)); + messages.push(format!("[OK] Stopped containers for '{}'", env_name)); } Ok(output) => { let stderr = String::from_utf8_lossy(&output.stderr); if !stderr.contains("No such file") && !stderr.to_lowercase().contains("not found") { eprintln!("[delete_environment] Warning: Failed to stop containers: {}", stderr); - messages.push(format!("⚠ Could not stop containers (may already be stopped)")); + messages.push(format!("[WARN] Could not stop containers (may already be stopped)")); } } Err(e) => { eprintln!("[delete_environment] Warning: Failed to run docker compose down: {}", e); - messages.push(format!("⚠ Could not stop containers (may already be stopped)")); + messages.push(format!("[WARN] Could not stop containers (may already be stopped)")); } } - // Step 2: Close tmux window if it exists - let window_name = format!("ushadow-{}", env_name); - eprintln!("[delete_environment] Closing tmux window '{}'...", window_name); - let close_result = shell_command(&format!("tmux kill-window -t {}", window_name)) + // Step 2: Kill the per-environment tmux session (ush-{env}) if it exists + let sanitized_env_name = env_name.replace('/', "-").replace('\\', "-"); + let session_name = format!("ush-{}", sanitized_env_name); + eprintln!("[delete_environment] Killing tmux session '{}'...", session_name); + let close_result = shell_command(&format!("tmux kill-session -t {}", session_name)) .output(); match close_result { Ok(output) if output.status.success() => { - messages.push(format!("✓ Closed tmux window '{}'", window_name)); + messages.push(format!("[OK] Killed tmux session '{}'", session_name)); } Ok(_) | Err(_) => { - // Tmux window might not exist, that's fine - eprintln!("[delete_environment] No tmux window found for '{}'", window_name); + // Session might not exist, that's fine + eprintln!("[delete_environment] No tmux session found for '{}'", session_name); } } @@ -721,7 +951,7 @@ pub async fn delete_environment(main_repo: String, env_name: String) -> Result { - messages.push(format!("✓ Removed worktree '{}'", env_name)); + messages.push(format!("[OK] Removed worktree '{}'", env_name)); } Err(e) => { return Err(format!("Failed to remove worktree: {}", e)); @@ -736,7 +966,7 @@ pub async fn delete_environment(main_repo: String, env_name: String) -> Result { // Error checking worktree, log but don't fail eprintln!("[delete_environment] Warning: Could not check worktree existence: {}", e); - messages.push(format!("⚠ Could not check for worktree")); + messages.push(format!("[WARN] Could not check for worktree")); } } @@ -749,61 +979,113 @@ pub async fn delete_environment(main_repo: String, env_name: String) -> Result, base_branch: Option, _background: Option, + custom_window_name: Option, ) -> Result { // Force lowercase to avoid Docker Compose naming issues let name = name.to_lowercase(); + let branch_name = branch_name.map(|b| b.to_lowercase()); let base_branch = base_branch.map(|b| b.to_lowercase()); - eprintln!("[create_worktree_with_workmux] Creating worktree '{}' from branch '{:?}'", name, base_branch); + eprintln!("[create_worktree_with_workmux] Creating worktree '{}' with branch '{:?}' from base '{:?}'", name, branch_name, base_branch); - // Use the launcher's own worktree creation logic instead of workmux - // This ensures consistent directory structure + // Hybrid approach: Create worktree manually for custom control, then register with workmux + // Manual creation ensures: custom directory naming, ticket-based window names, lowercase enforcement + // Workmux registration adds: dashboard visibility, lifecycle tracking let main_repo_path = PathBuf::from(&main_repo); + + // Calculate worktrees directory: ../worktrees (sibling to project root) let worktrees_dir = main_repo_path.parent() - .ok_or("Could not determine worktrees directory")? + .ok_or("Could not determine parent directory")? + .join("worktrees") .to_string_lossy() .to_string(); + eprintln!("[create_worktree_with_workmux] Worktrees directory: {}", worktrees_dir); + + // Clone branch_name before moving it into create_worktree so we can use it below + let branch_name_for_window = branch_name.clone(); + // Create the worktree directly - let worktree = create_worktree(main_repo.clone(), worktrees_dir, name.clone(), base_branch).await?; + let worktree = create_worktree(main_repo.clone(), worktrees_dir, name.clone(), branch_name, base_branch).await?; eprintln!("[create_worktree_with_workmux] Worktree created at: {}", worktree.path); - // Now attach tmux to the worktree - // Ensure tmux is running - let tmux_check = shell_command("tmux list-sessions") - .output(); - - let tmux_available = matches!(tmux_check, Ok(output) if output.status.success()); + // New model: one tmux session per environment, named ush-{env}. + // custom_window_name is kept in the signature for backwards-compat but is ignored. + let _ = custom_window_name; + let _ = branch_name_for_window; - if !tmux_available { - eprintln!("[create_worktree_with_workmux] tmux not running, attempting to start a new session"); - let _start_tmux = shell_command("tmux new-session -d -s workmux") - .output(); - } + let session_name = format!("ush-{}", name); - // Create tmux window for the worktree + // Window name: ushadow-{env_name} — workmux uses the worktree directory basename as its + // handle, so this must match {window_prefix}{dir_basename} from .workmux.yaml. + // Using the branch name here would break `workmux list`, `workmux dashboard`, and merge. let window_name = format!("ushadow-{}", name); - let create_window = shell_command(&format!( - "tmux new-window -t workmux -n {} -c '{}'", - window_name, worktree.path - )) + + eprintln!("[create_worktree_with_workmux] Target session '{}', window '{}'", session_name, window_name); + + // Check whether the session already exists + let session_exists = shell_command(&format!("tmux has-session -t {}", session_name)) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if !session_exists { + // S5: brand-new env — create session with the first window + eprintln!("[create_worktree_with_workmux] Session '{}' not found, creating it", session_name); + let create_result = shell_command(&format!( + "tmux new-session -d -s {} -c '{}' -n '{}'", + session_name, worktree.path, window_name + )) .output(); - match create_window { - Ok(output) if output.status.success() => { - eprintln!("[create_worktree_with_workmux] Created tmux window '{}'", window_name); - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - eprintln!("[create_worktree_with_workmux] Warning: Failed to create tmux window: {}", stderr); - // Don't fail the whole operation if tmux fails + match create_result { + Ok(output) if output.status.success() => { + eprintln!("[create_worktree_with_workmux] ✓ Created session '{}' with window '{}'", session_name, window_name); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("[create_worktree_with_workmux] Warning: Failed to create session: {}", stderr); + } + Err(e) => { + eprintln!("[create_worktree_with_workmux] Warning: Failed to create session: {}", e); + } } - Err(e) => { - eprintln!("[create_worktree_with_workmux] Warning: Failed to create tmux window: {}", e); - // Don't fail the whole operation if tmux fails + } else { + // Session already exists — add a window for this branch if not already present + let window_exists = shell_command(&format!( + "tmux list-windows -t {} -F '#{{window_name}}' 2>/dev/null | grep -Fx '{}'", + session_name, window_name + )) + .output() + .map(|o| o.status.success() && !String::from_utf8_lossy(&o.stdout).trim().is_empty()) + .unwrap_or(false); + + if !window_exists { + eprintln!("[create_worktree_with_workmux] Session exists, adding window '{}'", window_name); + let create_result = shell_command(&format!( + "tmux new-window -t {} -n '{}' -c '{}'", + session_name, window_name, worktree.path + )) + .output(); + + match create_result { + Ok(output) if output.status.success() => { + eprintln!("[create_worktree_with_workmux] ✓ Added window '{}' to session '{}'", window_name, session_name); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("[create_worktree_with_workmux] Warning: Failed to add window: {}", stderr); + } + Err(e) => { + eprintln!("[create_worktree_with_workmux] Warning: Failed to add window: {}", e); + } + } + } else { + eprintln!("[create_worktree_with_workmux] ✓ Window '{}' already exists in session '{}'", window_name, session_name); } } @@ -852,6 +1134,19 @@ pub async fn merge_worktree_with_rebase( return Err(format!("Workmux merge failed:\nstdout: {}\nstderr: {}", stdout, stderr)); } + // Kill the per-env session — workmux merge closes the window but leaves the session orphaned. + let session_name = format!("ush-{}", name); + let kill_result = shell_command(&format!("tmux kill-session -t {}", session_name)) + .output(); + match kill_result { + Ok(output) if output.status.success() => { + eprintln!("[merge_worktree_with_rebase] ✓ Killed tmux session '{}'", session_name); + } + _ => { + eprintln!("[merge_worktree_with_rebase] Note: session '{}' not found (already gone)", session_name); + } + } + Ok(format!("Merged and cleaned up worktree '{}'\n{}", name, stdout)) } @@ -960,72 +1255,150 @@ pub async fn get_tmux_info() -> Result { /// Attach or create a tmux window for an existing worktree #[tauri::command] -pub async fn attach_tmux_to_worktree(worktree_path: String, env_name: String) -> Result { - let env_name = env_name.to_lowercase(); - let window_name = format!("ushadow-{}", env_name); +pub async fn attach_tmux_to_worktree( + worktree_path: String, + env_name: String, + window_name_override: Option +) -> Result { + eprintln!("[attach_tmux_to_worktree] Attaching to worktree at: {}", worktree_path); - // Ensure tmux is running - ensure_tmux_running().await?; + // Extract worktree name from path for workmux + let worktree_name = std::path::Path::new(&worktree_path) + .file_name() + .ok_or("Invalid worktree path")? + .to_str() + .ok_or("Path contains invalid UTF-8")?; - // Check if window already exists - let check_window = shell_command(&format!("tmux list-windows -a -F '#{{window_name}}' | grep '^{}'", window_name)) - .output(); + eprintln!("[attach_tmux_to_worktree] Using workmux to open worktree: {}", worktree_name); - let window_existed = matches!(check_window, Ok(ref output) if output.status.success()); + // Use workmux open which handles everything: + // - Creates workmux session if needed + // - Creates window if needed + // - Reuses window if exists + // - Sets up working directory correctly + // - Registers in dashboard + let workmux_cmd = format!("cd '{}' && workmux open {}", worktree_path, worktree_name); - // Create window if it doesn't exist - if !window_existed { - let create_window = shell_command(&format!( - "tmux new-window -t workmux -n {} -c '{}'", - window_name, worktree_path - )) - .output() - .map_err(|e| format!("Failed to create tmux window: {}", e))?; + let open_result = shell_command(&workmux_cmd).output(); - if !create_window.status.success() { - let stderr = String::from_utf8_lossy(&create_window.stderr); - return Err(format!("Failed to create tmux window: {}", stderr)); + match open_result { + Ok(output) if output.status.success() => { + eprintln!("[attach_tmux_to_worktree] ✓ Workmux window opened/reused"); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("[attach_tmux_to_worktree] Workmux open warning: {}", stderr); + } + Err(e) => { + return Err(format!("Failed to open workmux window: {}", e)); } } - // Open Terminal.app and attach to the tmux window + // workmux open manages the tmux side. Now we need to make it visible in iTerm. + // workmux open always uses the "workmux" session. + let window_name_derived = format!("ushadow-{}", env_name.to_lowercase()); + + // Pre-select the window so the terminal attach lands on the right pane. + let _ = shell_command(&format!( + "tmux select-window -t workmux:'{}'", + window_name_derived + )) + .output(); + + // Spawn agent resume in background (after window is selected). + let wpath = worktree_path.clone(); + let wname = window_name_derived.clone(); + tauri::async_runtime::spawn(async move { + let _ = check_and_resume_agent("workmux", &wname, &wpath).await; + }); + #[cfg(target_os = "macos")] { - let script = format!( - "tell application \"Terminal\" to do script \"tmux attach-session -t workmux:{} && exit\"", - window_name + use std::fs; + + // Check if an iTerm window is already showing the workmux session. + let find_script = format!( + r#"tell application "iTerm" + set matched to false + repeat with w in windows + if name of w contains "{}" then + select w + set matched to true + exit repeat + end if + end repeat + if matched then + return "found" + else + return "not_found" + end if +end tell"#, + env_name ); - let open_terminal = Command::new("osascript") + let iterm_running = Command::new("osascript") .arg("-e") - .arg(&script) + .arg("application \"iTerm\" is running") .output() - .map_err(|e| format!("Failed to open Terminal: {}", e))?; + .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true") + .unwrap_or(false); - if !open_terminal.status.success() { - let stderr = String::from_utf8_lossy(&open_terminal.stderr); - eprintln!("[attach_tmux_to_worktree] Warning: Failed to open Terminal.app: {}", stderr); - // Don't fail the whole operation if Terminal opening fails + let found_window = if iterm_running { + Command::new("osascript") + .arg("-e") + .arg(&find_script) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim() == "found") + .unwrap_or(false) + } else { + false + }; + + if found_window { + eprintln!("[attach_tmux_to_worktree] S2: Focused existing iTerm window for '{}'", env_name); + let _ = Command::new("osascript") + .arg("-e") + .arg(r#"tell application "iTerm" to activate"#) + .output(); + } else { + // Open a new iTerm window attached to the workmux session. + // The session-select above already moved focus to the right window. + eprintln!("[attach_tmux_to_worktree] S3: Opening new iTerm window for '{}'", env_name); + let temp_script = format!("/tmp/ushadow_attach_{}.sh", env_name.replace('/', "_")); + let script_content = format!( + "#!/bin/bash\nprintf '\\033]0;{}\\007'\nexec tmux attach-session -t workmux\n", + env_name + ); + if fs::write(&temp_script, &script_content).is_ok() { + let _ = shell_command(&format!("chmod +x {}", temp_script)).output(); + let applescript = format!( + r#"tell application "iTerm" + activate + set newWindow to (create window with default profile) + tell current session of newWindow + write text "{} && exit" + end tell +end tell"#, + temp_script + ); + let _ = Command::new("osascript") + .arg("-e") + .arg(&applescript) + .output(); + } } + + eprintln!("[attach_tmux_to_worktree] iTerm opened/focused for '{}'", env_name); } #[cfg(not(target_os = "macos"))] { - // For Linux/Windows, try using default terminal - let _open_terminal = Command::new("x-terminal-emulator") - .arg("-e") - .arg(format!("tmux attach-session -t workmux:{}", window_name)) - .spawn(); - // Don't fail if this doesn't work + let _ = Command::new("wmctrl").arg("-a").arg("tmux").spawn(); } - let message = if window_existed { - format!("Opened tmux window '{}' (already existed)", window_name) - } else { - format!("Created and opened tmux window '{}' in {}", window_name, worktree_path) - }; - - Ok(message) + Ok(format!("Attached to worktree at {}", worktree_path)) } /// Get comprehensive tmux status for an environment @@ -1202,74 +1575,203 @@ pub async fn kill_tmux_server() -> Result { Ok("Killed tmux server".to_string()) } -/// Open a tmux window in iTerm2 (falls back to Terminal.app if not available) +/// Open a tmux session in iTerm2 (falls back to Terminal.app if not available). +/// +/// Implements three scenarios from the agent window spec: +/// - S1: No `ush-{env}` session → create session + window, open new iTerm window +/// - S2: Session + iTerm window (title contains env_name) → focus existing iTerm window +/// - S3: Session exists, no matching iTerm window → open new iTerm window attaching to session #[tauri::command] -pub async fn open_tmux_in_terminal(window_name: String, worktree_path: String) -> Result { - eprintln!("[open_tmux_in_terminal] Opening tmux window: {} at path: {}", window_name, worktree_path); +pub async fn open_tmux_in_terminal( + window_name: String, + worktree_path: String, + environment_name: Option, +) -> Result { + use std::fs; + + // Derive the environment name from the parameter, or fall back to the last + // component of the worktree path (e.g. "beige" from ".../worktrees/ushadow/beige"). + let env_name: String = environment_name + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| { + std::path::Path::new(&worktree_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("ushadow") + .to_string() + }); + + // Sanitize window_name for tmux (slashes not allowed in window names) + let sanitized_window = window_name.replace('/', "-").replace('\\', "-"); + + let session_name = format!("ush-{}", env_name); + + eprintln!( + "[open_tmux_in_terminal] env='{}' session='{}' window='{}' path='{}'", + env_name, session_name, sanitized_window, worktree_path + ); + + // ── Ensure the tmux session exists; manage windows only if a branch is given ── + let session_exists = shell_command(&format!("tmux has-session -t {}", session_name)) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if !session_exists { + // S1: create the session. If we have a target window name use it; otherwise + // create a default window named after the env. + let first_window = if sanitized_window.is_empty() { env_name.clone() } else { sanitized_window.clone() }; + eprintln!("[open_tmux_in_terminal] Session '{}' not found, creating with window '{}' (S1)", session_name, first_window); + match shell_command(&format!( + "tmux new-session -d -s {} -c '{}' -n '{}'", + session_name, worktree_path, first_window + )) + .output() + { + Ok(output) if output.status.success() => { + eprintln!("[open_tmux_in_terminal] ✓ Created session '{}'", session_name); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("[open_tmux_in_terminal] Warning: Failed to create session: {}", stderr); + } + Err(e) => { + eprintln!("[open_tmux_in_terminal] Warning: Failed to create session: {}", e); + } + } + } else if !sanitized_window.is_empty() { + // Session exists and a specific window was requested — ensure it exists and select it + let window_exists = shell_command(&format!( + "tmux list-windows -t {} -F '#{{window_name}}' 2>/dev/null | grep -Fx '{}'", + session_name, sanitized_window + )) + .output() + .map(|o| o.status.success() && !String::from_utf8_lossy(&o.stdout).trim().is_empty()) + .unwrap_or(false); + + if !window_exists { + eprintln!( + "[open_tmux_in_terminal] Window '{}' missing from session '{}', creating it", + sanitized_window, session_name + ); + let _ = shell_command(&format!( + "tmux new-window -t {} -n '{}' -c '{}'", + session_name, sanitized_window, worktree_path + )) + .output(); + } + // Pre-select the window so attach lands on it + let _ = shell_command(&format!( + "tmux select-window -t {}:'{}'", + session_name, sanitized_window + )) + .output(); + eprintln!("[open_tmux_in_terminal] Selected window '{}' in session '{}'", sanitized_window, session_name); + } else { + eprintln!("[open_tmux_in_terminal] Session exists, no specific window requested — will attach to current window (S2/S3)"); + } + + // ── Spawn agent start/resume in background (only when a specific window is known) ── + if !sanitized_window.is_empty() { + let sess = session_name.clone(); + let win = sanitized_window.clone(); + let wpath = worktree_path.clone(); + tauri::async_runtime::spawn(async move { + let _ = check_and_resume_agent(&sess, &win, &wpath).await; + }); + } + + // ── macOS: S2 = focus existing iTerm window; S3/S1 = open new one ────── #[cfg(target_os = "macos")] { - // Check if iTerm2 is installed and available - let iterm_check = Command::new("osascript") + // Map env names to RGB colors (0-255) + let (r, g, b) = match env_name.as_str() { + "gold" | "yellow" => (255, 215, 0), + "green" => (0, 200, 80), + "red" => (255, 60, 60), + "purple" | "pink" => (200, 0, 255), + "orange" => (255, 165, 0), + "blue" => (50, 120, 255), + "cyan" => (0, 220, 220), + "brown" => (165, 42, 42), + "beige" => (220, 200, 160), + _ => (128, 128, 128), + }; + + // S2: Check for an existing iTerm window whose title contains the env name. + // The attach script sets the window title to the env_name via \033]0;{env_name}\007. + let find_script = format!( + r#"tell application "iTerm" + set matched to false + repeat with w in windows + if name of w contains "{}" then + select w + set matched to true + exit repeat + end if + end repeat + if matched then + return "found" + else + return "not_found" + end if +end tell"#, + env_name + ); + + let iterm_running = Command::new("osascript") .arg("-e") .arg("application \"iTerm\" is running") - .output(); - - let iterm_available = iterm_check + .output() .map(|o| o.status.success()) .unwrap_or(false); - if iterm_available { - eprintln!("[open_tmux_in_terminal] Using iTerm2"); - - // Extract env name from window name (format: "ushadow-{env}") - let env_name = window_name.strip_prefix("ushadow-").unwrap_or(&window_name); - let display_name = format!("Ushadow: {}", env_name); - - // Map env names to RGB colors (0-255) - let (r, g, b) = match env_name { - "gold" | "yellow" => (255, 215, 0), // Gold/Yellow - "green" => (0, 255, 0), // Green - "red" => (255, 0, 0), // Red - "purple" | "pink" => (255, 0, 255), // Purple/Magenta - "orange" => (255, 165, 0), // Orange - "blue" => (0, 0, 255), // Blue - "cyan" => (0, 255, 255), // Cyan - "brown" => (165, 42, 42), // Brown - _ => (128, 128, 128), // Gray for unknown - }; - - // Create temp script file with color sequences to avoid quote escaping hell - use std::fs; - let temp_script = format!("/tmp/ushadow_iterm_{}.sh", window_name.replace("/", "_")); + if iterm_running { + let find_result = Command::new("osascript") + .arg("-e") + .arg(&find_script) + .output(); + + let found_window = find_result + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim() == "found") + .unwrap_or(false); + + if found_window { + eprintln!("[open_tmux_in_terminal] S2: Found existing iTerm window for '{}', focused it", env_name); + // Activate iTerm to bring it to front + let _ = Command::new("osascript") + .arg("-e") + .arg(r#"tell application "iTerm" to activate"#) + .output(); + return Ok(format!("Focused existing iTerm window for '{}'", env_name)); + } + eprintln!("[open_tmux_in_terminal] S1/S3: No iTerm window for '{}', opening new one", env_name); + + // Write a temp attach script that sets the iTerm title and attaches to the session + let temp_script = format!("/tmp/ushadow_iterm_{}.sh", env_name.replace("/", "_")); let script_content = format!( - "#!/bin/bash\nprintf '\\033]0;{}\\007\\033]6;1;bg;red;brightness;{}\\007\\033]6;1;bg;green;brightness;{}\\007\\033]6;1;bg;blue;brightness;{}\\007'\n# Create dedicated session for this environment if it doesn't exist\ntmux has-session -t {} 2>/dev/null || tmux new-session -d -s {} -c '{}'\n# Attach to this environment's dedicated session\nexec tmux attach-session -t {}\n", - display_name, - r, g, b, - window_name, - window_name, - worktree_path, - window_name + "#!/bin/bash\nprintf '\\033]0;{}\\007\\033]6;1;bg;red;brightness;{}\\007\\033]6;1;bg;green;brightness;{}\\007\\033]6;1;bg;blue;brightness;{}\\007'\nexec tmux attach-session -t {}\n", + env_name, r, g, b, session_name ); - fs::write(&temp_script, script_content) - .map_err(|e| format!("Failed to write temp script: {}", e))?; - + fs::write(&temp_script, &script_content) + .map_err(|e| format!("Failed to write attach script: {}", e))?; shell_command(&format!("chmod +x {}", temp_script)) .output() - .map_err(|e| format!("Failed to chmod: {}", e))?; + .map_err(|e| format!("Failed to chmod attach script: {}", e))?; - // Simple iTerm2 AppleScript that executes the script let applescript = format!( r#"tell application "iTerm" activate set newWindow to (create window with default profile) tell current session of newWindow - set name to "{}" write text "{} && exit" end tell end tell"#, - display_name, temp_script ); @@ -1280,37 +1782,27 @@ end tell"#, .map_err(|e| format!("Failed to run iTerm2 AppleScript: {}", e))?; if output.status.success() { - eprintln!("[open_tmux_in_terminal] iTerm2 success"); - return Ok(format!("Opened tmux window '{}' in iTerm2", window_name)); + eprintln!("[open_tmux_in_terminal] ✓ Opened new iTerm window for session '{}'", session_name); + return Ok(format!("Opened iTerm window for '{}'", env_name)); } else { let stderr = String::from_utf8_lossy(&output.stderr); eprintln!("[open_tmux_in_terminal] iTerm2 failed: {}, falling back to Terminal.app", stderr); } } else { - eprintln!("[open_tmux_in_terminal] iTerm2 not available, using Terminal.app"); + eprintln!("[open_tmux_in_terminal] iTerm not running, trying Terminal.app"); } - // Fallback to Terminal.app (macOS default terminal) - let env_name = window_name.strip_prefix("ushadow-").unwrap_or(&window_name); - let display_name = format!("Ushadow: {}", env_name); - - // Create temp script for Terminal.app (simpler than iTerm2, no tab colors) - use std::fs; - let temp_script = format!("/tmp/ushadow_terminal_{}.sh", window_name.replace("/", "_")); + // Fallback: Terminal.app + let temp_script = format!("/tmp/ushadow_terminal_{}.sh", env_name.replace("/", "_")); let script_content = format!( - "#!/bin/bash\nprintf '\\033]0;{}\\007'\n# Create dedicated session for this environment if it doesn't exist\ntmux has-session -t {} 2>/dev/null || tmux new-session -d -s {} -c '{}'\n# Attach to this environment's dedicated session\nexec tmux attach-session -t {}\n", - display_name, - window_name, - window_name, - worktree_path, - window_name + "#!/bin/bash\nprintf '\\033]0;{}\\007'\nexec tmux attach-session -t {}\n", + env_name, session_name ); - fs::write(&temp_script, script_content) - .map_err(|e| format!("Failed to write temp script: {}", e))?; - + fs::write(&temp_script, &script_content) + .map_err(|e| format!("Failed to write Terminal attach script: {}", e))?; shell_command(&format!("chmod +x {}", temp_script)) .output() - .map_err(|e| format!("Failed to chmod: {}", e))?; + .map_err(|e| format!("Failed to chmod Terminal attach script: {}", e))?; let applescript = format!( r#"tell application "Terminal" @@ -1328,41 +1820,28 @@ end tell"#, if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("Failed to open Terminal: {}", stderr)); + return Err(format!("Failed to open Terminal.app: {}", stderr)); } - eprintln!("[open_tmux_in_terminal] Terminal.app success"); - Ok(format!("Opened tmux window '{}' in Terminal.app", window_name)) + eprintln!("[open_tmux_in_terminal] ✓ Terminal.app success"); + Ok(format!("Opened Terminal.app for session '{}'", session_name)) } #[cfg(not(target_os = "macos"))] { - // For Linux/Windows - create dedicated tmux session per environment - // Try common terminal emulators in order of preference - let _env_name = window_name.strip_prefix("ushadow-").unwrap_or(&window_name); - - // Create the tmux session if it doesn't exist - let _ = shell_command(&format!( - "tmux has-session -t {} 2>/dev/null || tmux new-session -d -s {} -c '{}'", - window_name, window_name, worktree_path - )).output(); - - // Create tmux commands before the array to extend their lifetime - let xfce_cmd = format!("tmux attach-session -t {}", window_name); - let xterm_cmd = format!("tmux attach-session -t {}", window_name); - - // Try different terminal emulators - let terminals = vec![ - ("gnome-terminal", vec!["--", "tmux", "attach-session", "-t", &window_name]), - ("konsole", vec!["-e", "tmux", "attach-session", "-t", &window_name]), - ("xfce4-terminal", vec!["-e", xfce_cmd.as_str()]), - ("xterm", vec!["-e", xterm_cmd.as_str()]), + // Linux: try common terminal emulators + let attach_cmd = format!("tmux attach-session -t {}", session_name); + let terminals: Vec<(&str, Vec<&str>)> = vec![ + ("gnome-terminal", vec!["--", "tmux", "attach-session", "-t", &session_name]), + ("konsole", vec!["-e", "tmux", "attach-session", "-t", &session_name]), + ("xfce4-terminal", vec!["-e", attach_cmd.as_str()]), + ("xterm", vec!["-e", attach_cmd.as_str()]), ]; for (terminal, args) in terminals { - if let Ok(_) = Command::new(terminal).args(&args).spawn() { - eprintln!("[open_tmux_in_terminal] Opened {} for session {}", terminal, window_name); - return Ok(format!("Opened tmux session '{}' in {}", window_name, terminal)); + if Command::new(terminal).args(&args).spawn().is_ok() { + eprintln!("[open_tmux_in_terminal] Opened {} for session {}", terminal, session_name); + return Ok(format!("Opened {} for session '{}'", terminal, session_name)); } } @@ -1370,6 +1849,141 @@ end tell"#, } } +/// Check if Claude agent is running in a tmux window; start or resume it if not. +/// +/// Always tries `claude --resume` first so the user gets their last conversation back. +/// If Claude starts fresh (no prior session), and there's a ticket for this worktree, +/// sends the ticket title + description as initial context. +pub async fn check_and_resume_agent( + tmux_session_name: &str, + tmux_window_name: &str, + worktree_path: &str, +) -> Result { + use super::kanban::get_ticket_by_worktree_path; + + eprintln!("[check_and_resume_agent] Checking agent status for window {}", tmux_window_name); + + // 1. Check the current foreground process in the pane — this is reliable because + // Claude's startup banner stays in the scrollback after it exits, so scanning + // pane text gives false positives. + // Use shell_command but take only the last non-empty line — login shell profile + // output (e.g. workmux version banner) appears before the tmux output and was + // being mistaken for the pane command. + let current_command = shell_command(&format!( + "tmux display-message -t {}:{} -p '#{{pane_current_command}}'", + tmux_session_name, tmux_window_name + )) + .output() + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .filter(|l| !l.trim().is_empty()) + .last() + .unwrap_or("") + .trim() + .to_string() + }) + .unwrap_or_default(); + + eprintln!("[check_and_resume_agent] Current pane command: '{}'", current_command); + + // Shells indicate Claude is not running; anything else (claude, node, python…) is. + let is_shell = matches!(current_command.as_str(), "zsh" | "bash" | "sh" | "fish" | ""); + if !is_shell { + eprintln!("[check_and_resume_agent] Agent already running ({}), no action needed", current_command); + return Ok(false); + } + + // 2. Find the most-recently-modified Claude session file for this worktree. + // Pass its UUID directly to --resume so Claude skips the picker entirely. + // Bare `claude --resume` without a session ID shows an interactive chooser + // whenever multiple sessions exist — not what we want. + let home_dir = std::env::var("HOME").unwrap_or_default(); + let encoded_path = worktree_path.replace('/', "-"); + let sessions_dir = format!("{}/.claude/projects/{}", home_dir, encoded_path); + + // Walk the directory, collect (modified_time, session_id) for every .jsonl file, + // then pick the most recently modified one. + let latest_session_id: Option = std::fs::read_dir(&sessions_dir) + .ok() + .map(|entries| { + let mut candidates: Vec<(std::time::SystemTime, String)> = entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().map(|x| x == "jsonl").unwrap_or(false)) + .filter_map(|e| { + let session_id = e.path() + .file_stem()? + .to_str()? + .to_string(); + let modified = e.metadata().ok()?.modified().ok()?; + Some((modified, session_id)) + }) + .collect(); + candidates.sort_by(|a, b| b.0.cmp(&a.0)); // newest first + candidates.into_iter().next().map(|(_, id)| id) + }) + .flatten(); + + // Build the claude invocation, writing it to a temp script to avoid multi-layer + // quoting issues. tmux send-keys passes each argument as separate keystroke runs, + // so any unbalanced inner quotes turn "You are working" into "Youareworking". + // A temp script path has no special characters, so the outer quoting is trivially safe. + // Inside the script we use bash $'...' (ANSI-C quoting) which handles \' and \n cleanly. + let script_key = tmux_window_name.replace('/', "_"); + let temp_script = format!("/tmp/ushadow_claude_{}.sh", script_key); + + let script_content = if let Some(session_id) = latest_session_id { + eprintln!("[check_and_resume_agent] Resuming session {} (no picker)", session_id); + format!( + "#!/bin/bash\nexec claude --resume {} --dangerously-skip-permissions\n", + session_id + ) + } else { + let ticket = get_ticket_by_worktree_path(worktree_path); + if let Some(ticket) = ticket { + eprintln!("[check_and_resume_agent] No sessions — starting fresh with ticket context: {}", ticket.title); + let prompt = format!( + "You are working on the following ticket:\n\nTitle: {}\n\nDescription: {}\n\nPlease help implement this feature.", + ticket.title, + ticket.description.as_ref().unwrap_or(&"No description".to_string()) + ); + // Escape for bash $'...' ANSI-C quoting: backslash → \\, single-quote → \', newline → \n + let ansi_escaped = prompt + .replace('\\', "\\\\") + .replace('\'', "\\'") + .replace('\n', "\\n"); + format!( + "#!/bin/bash\nexec claude --dangerously-skip-permissions $'{}'\n", + ansi_escaped + ) + } else { + eprintln!("[check_and_resume_agent] No sessions, no ticket — starting plain Claude"); + "#!/bin/bash\nexec claude --dangerously-skip-permissions\n".to_string() + } + }; + + if let Err(e) = std::fs::write(&temp_script, &script_content) { + eprintln!("[check_and_resume_agent] Warning: could not write temp script: {}", e); + } else { + let _ = shell_command(&format!("chmod +x {}", temp_script)).output(); + } + + // tmux send-keys just types the script path — no special characters, no quoting issues + eprintln!("[check_and_resume_agent] Running via script: {}", temp_script); + let result = shell_command(&format!( + "tmux send-keys -t {}:{} 'bash {}' Enter", + tmux_session_name, tmux_window_name, temp_script + )) + .output(); + + if let Err(e) = result { + eprintln!("[check_and_resume_agent] Failed to start Claude: {}", e); + return Ok(false); + } + + Ok(true) +} + /// Capture the visible content of a tmux pane #[tauri::command] pub async fn capture_tmux_pane(window_name: String) -> Result { diff --git a/ushadow/launcher/src-tauri/src/config.rs b/ushadow/launcher/src-tauri/src/config.rs new file mode 100644 index 00000000..8a466751 --- /dev/null +++ b/ushadow/launcher/src-tauri/src/config.rs @@ -0,0 +1,356 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Main launcher configuration loaded from .launcher-config.yaml +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct LauncherConfig { + pub project: ProjectConfig, + pub prerequisites: PrerequisitesConfig, + pub setup: SetupConfig, + pub infrastructure: InfrastructureConfig, + pub containers: ContainersConfig, + pub ports: PortsConfig, + pub worktrees: WorktreesConfig, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct ProjectConfig { + pub name: String, + pub display_name: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct PrerequisitesConfig { + pub required: Vec, + #[serde(default)] + pub optional: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct SetupConfig { + pub command: String, + #[serde(default)] + pub env_vars: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct InfrastructureConfig { + pub compose_file: String, + pub project_name: String, + pub profile: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct ContainersConfig { + pub naming_pattern: String, + pub primary_service: String, + pub health_endpoint: String, + /// Optional project prefix for Tailscale hostnames (for multi-project setups) + /// If set, Tailscale URLs will be: https://{prefix}-{env}.{tailnet} + /// If not set, Tailscale URLs will be: https://{env}.{tailnet} + #[serde(default)] + pub tailscale_project_prefix: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct PortsConfig { + #[serde(default = "default_allocation_strategy")] + pub allocation_strategy: String, // "hash", "sequential", "random" + pub base_port: u16, + pub offset: PortOffset, +} + +fn default_allocation_strategy() -> String { + "hash".to_string() +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct PortOffset { + pub min: u16, + pub max: u16, + pub step: u16, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct WorktreesConfig { + pub default_parent: String, + #[serde(default)] + pub branch_prefix: String, +} + +impl LauncherConfig { + /// Load configuration from .launcher-config.yaml in the project root + pub fn load(project_root: &PathBuf) -> Result { + let config_path = project_root.join(".launcher-config.yaml"); + + if !config_path.exists() { + return Err(format!( + "Configuration file not found: {}\n\n\ + This repository is not configured for the launcher.\n\ + Please create a .launcher-config.yaml file in the repository root.", + config_path.display() + )); + } + + let contents = std::fs::read_to_string(&config_path) + .map_err(|e| format!("Failed to read config file: {}", e))?; + + let config: LauncherConfig = serde_yaml::from_str(&contents) + .map_err(|e| format!("Failed to parse config YAML: {}", e))?; + + // Validate the configuration + config.validate()?; + + Ok(config) + } + + /// Validate the configuration structure and constraints + fn validate(&self) -> Result<(), String> { + // Validate project basics + if self.project.name.is_empty() { + return Err("project.name cannot be empty".to_string()); + } + + // Validate setup command + if self.setup.command.is_empty() { + return Err("setup.command cannot be empty".to_string()); + } + + // Validate port ranges + if self.ports.base_port == 0 { + return Err("ports.base_port must be greater than 0".to_string()); + } + + if self.ports.offset.max > 60000 { + return Err(format!( + "ports.offset.max ({}) too large - max allowed is 60000 to prevent exceeding port 65535", + self.ports.offset.max + )); + } + + // Validate container naming pattern contains required variables + if !self.containers.naming_pattern.contains("{project_name}") { + return Err("containers.naming_pattern must contain {project_name}".to_string()); + } + + if !self.containers.naming_pattern.contains("{service_name}") { + return Err("containers.naming_pattern must contain {service_name}".to_string()); + } + + // Validate infrastructure config + if self.infrastructure.compose_file.is_empty() { + return Err("infrastructure.compose_file cannot be empty".to_string()); + } + + Ok(()) + } + + /// Expand variables in a string template using provided context + pub fn expand_variables(&self, template: &str, vars: &HashMap) -> String { + let mut result = template.to_string(); + for (key, value) in vars { + result = result.replace(&format!("{{{}}}", key), value); + } + result + } + + /// Generate container name from pattern + pub fn generate_container_name(&self, env_name: &str, service_name: &str) -> String { + let env_suffix = if env_name == "default" || env_name.is_empty() { + String::new() + } else { + format!("-{}", env_name) + }; + + self.containers + .naming_pattern + .replace("{project_name}", &self.project.name) + .replace("{env_name}", &env_suffix) + .replace("{service_name}", service_name) + .replace("--", "-") // Clean up double dashes + } + + /// Calculate port for an environment given the base port and env name + pub fn calculate_port(&self, env_name: &str) -> u16 { + if env_name == "default" || env_name == "main" || env_name.is_empty() { + return self.ports.base_port; + } + + match self.ports.allocation_strategy.as_str() { + "hash" => { + let hash: u32 = env_name.bytes().map(|b| b as u32).sum(); + let offset_steps = (self.ports.offset.max - self.ports.offset.min) / self.ports.offset.step; + let offset = ((hash % offset_steps as u32) * self.ports.offset.step as u32) as u16; + self.ports.base_port + offset + } + "sequential" => { + // For sequential, would need to track allocated ports in state + // For now, fall back to hash + let hash: u32 = env_name.bytes().map(|b| b as u32).sum(); + let offset_steps = (self.ports.offset.max - self.ports.offset.min) / self.ports.offset.step; + let offset = ((hash % offset_steps as u32) * self.ports.offset.step as u32) as u16; + self.ports.base_port + offset + } + _ => self.ports.base_port, // Default/random strategy + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_container_naming() { + let config = LauncherConfig { + project: ProjectConfig { + name: "myapp".to_string(), + display_name: "My App".to_string(), + }, + containers: ContainersConfig { + naming_pattern: "{project_name}{env_name}-{service_name}".to_string(), + primary_service: "backend".to_string(), + health_endpoint: "/health".to_string(), + tailscale_project_prefix: None, + }, + prerequisites: PrerequisitesConfig { + required: vec![], + optional: vec![], + }, + setup: SetupConfig { + command: "setup.sh".to_string(), + env_vars: vec![], + }, + infrastructure: InfrastructureConfig { + compose_file: "docker-compose.yml".to_string(), + project_name: "infra".to_string(), + profile: None, + }, + ports: PortsConfig { + allocation_strategy: "hash".to_string(), + base_port: 8000, + offset: PortOffset { + min: 0, + max: 500, + step: 10, + }, + }, + worktrees: WorktreesConfig { + default_parent: "~/repos".to_string(), + branch_prefix: "".to_string(), + }, + }; + + assert_eq!( + config.generate_container_name("default", "backend"), + "myapp-backend" + ); + assert_eq!( + config.generate_container_name("staging", "backend"), + "myapp-staging-backend" + ); + } + + #[test] + fn test_variable_expansion() { + let config = LauncherConfig { + project: ProjectConfig { + name: "test".to_string(), + display_name: "Test".to_string(), + }, + prerequisites: PrerequisitesConfig { + required: vec![], + optional: vec![], + }, + setup: SetupConfig { + command: "setup.sh {ENV_NAME} {PORT}".to_string(), + env_vars: vec![], + }, + containers: ContainersConfig { + naming_pattern: "test-{service_name}".to_string(), + primary_service: "backend".to_string(), + health_endpoint: "/health".to_string(), + tailscale_project_prefix: None, + }, + infrastructure: InfrastructureConfig { + compose_file: "docker-compose.yml".to_string(), + project_name: "infra".to_string(), + profile: None, + }, + ports: PortsConfig { + allocation_strategy: "hash".to_string(), + base_port: 8000, + offset: PortOffset { + min: 0, + max: 500, + step: 10, + }, + }, + worktrees: WorktreesConfig { + default_parent: "~/repos".to_string(), + branch_prefix: "".to_string(), + }, + }; + + let mut vars = HashMap::new(); + vars.insert("ENV_NAME".to_string(), "staging".to_string()); + vars.insert("PORT".to_string(), "8080".to_string()); + + let expanded = config.expand_variables(&config.setup.command, &vars); + assert_eq!(expanded, "setup.sh staging 8080"); + } + + #[test] + fn test_port_calculation() { + let config = LauncherConfig { + project: ProjectConfig { + name: "test".to_string(), + display_name: "Test".to_string(), + }, + prerequisites: PrerequisitesConfig { + required: vec![], + optional: vec![], + }, + setup: SetupConfig { + command: "setup.sh".to_string(), + env_vars: vec![], + }, + containers: ContainersConfig { + naming_pattern: "test-{service_name}".to_string(), + primary_service: "backend".to_string(), + health_endpoint: "/health".to_string(), + tailscale_project_prefix: None, + }, + infrastructure: InfrastructureConfig { + compose_file: "docker-compose.yml".to_string(), + project_name: "infra".to_string(), + profile: None, + }, + ports: PortsConfig { + allocation_strategy: "hash".to_string(), + base_port: 8000, + offset: PortOffset { + min: 0, + max: 500, + step: 10, + }, + }, + worktrees: WorktreesConfig { + default_parent: "~/repos".to_string(), + branch_prefix: "".to_string(), + }, + }; + + // Default environment gets base port + assert_eq!(config.calculate_port("default"), 8000); + assert_eq!(config.calculate_port("main"), 8000); + + // Other environments get offset ports (deterministic hash) + let staging_port = config.calculate_port("staging"); + assert!(staging_port >= 8000 && staging_port <= 8500); + + // Same env name should always give same port + assert_eq!(staging_port, config.calculate_port("staging")); + } +} diff --git a/ushadow/launcher/src-tauri/src/main.rs b/ushadow/launcher/src-tauri/src/main.rs index f4dfa294..0d432e86 100644 --- a/ushadow/launcher/src-tauri/src/main.rs +++ b/ushadow/launcher/src-tauri/src/main.rs @@ -4,31 +4,50 @@ )] mod commands; +mod config; mod models; use commands::{AppState, check_prerequisites, discover_environments, get_os_type, - discover_environments_with_config, + // Claude session monitoring + install_claude_hooks, read_claude_sessions, get_hooks_installed, read_claude_transcript, + send_claude_approval, + discover_environments_with_config, discover_environments_v2, start_containers, stop_containers, get_container_status, start_infrastructure, stop_infrastructure, restart_infrastructure, start_environment, stop_environment, check_ports, check_backend_health, check_webui_health, open_browser, focus_window, set_project_root, create_environment, + // OAuth server commands + start_oauth_server, wait_for_oauth_callback, + // HTTP client + http_request, // Project/repo management (from repository.rs) get_default_project_dir, check_project_dir, clone_ushadow_repo, update_ushadow_repo, get_current_branch, checkout_branch, get_base_branch, // Worktree commands - list_worktrees, list_git_branches, check_worktree_exists, create_worktree, create_worktree_with_workmux, + list_worktrees, list_git_branches, check_worktree_exists, check_environment_conflict, create_worktree, create_worktree_with_workmux, merge_worktree_with_rebase, list_tmux_sessions, get_tmux_window_status, get_environment_tmux_status, get_tmux_info, ensure_tmux_running, attach_tmux_to_worktree, open_in_vscode, open_in_vscode_with_tmux, remove_worktree, delete_environment, get_tmux_sessions, kill_tmux_window, kill_tmux_server, open_tmux_in_terminal, capture_tmux_pane, get_claude_status, + // Kanban ticket commands + create_ticket_worktree, attach_ticket_to_worktree, get_tickets_for_tmux_window, get_ticket_tmux_info, + start_coding_agent_for_ticket, + // Kanban ticket/epic CRUD (local storage) + get_tickets, get_epics, create_ticket, update_ticket, delete_ticket, create_epic, update_epic, delete_epic, // Settings load_launcher_settings, save_launcher_settings, write_credentials_to_worktree, // Prerequisites config (from prerequisites_config.rs) get_prerequisites_config, get_platform_prerequisites_config, // Generic installer (from generic_installer.rs) - replaces all platform-specific installers install_prerequisite, start_prerequisite, + // Config commands (from 4bdc-ushadow-launchge) + load_project_config, get_current_config, check_launcher_config_exists, validate_config_file, + // Environment scanning + scan_env_file, scan_all_env_vars, + // Infrastructure discovery + get_infra_services_from_compose, // Permissions check_install_path}; use tauri::{ @@ -51,15 +70,54 @@ fn create_tray_menu() -> SystemTrayMenu { fn create_app_menu() -> Menu { let launcher = CustomMenuItem::new("show_launcher", "Show Launcher"); + // App menu (File on Windows/Linux, App name on macOS) let app_menu = Submenu::new( "Ushadow", Menu::new() .add_item(launcher) .add_native_item(MenuItem::Separator) + .add_native_item(MenuItem::Hide) + .add_native_item(MenuItem::HideOthers) + .add_native_item(MenuItem::ShowAll) + .add_native_item(MenuItem::Separator) .add_native_item(MenuItem::Quit), ); - Menu::new().add_submenu(app_menu) + // Edit menu with all standard shortcuts + let edit_menu = Submenu::new( + "Edit", + Menu::new() + .add_native_item(MenuItem::Undo) + .add_native_item(MenuItem::Redo) + .add_native_item(MenuItem::Separator) + .add_native_item(MenuItem::Cut) + .add_native_item(MenuItem::Copy) + .add_native_item(MenuItem::Paste) + .add_native_item(MenuItem::SelectAll), + ); + + // View menu + let view_menu = Submenu::new( + "View", + Menu::new() + .add_native_item(MenuItem::EnterFullScreen), + ); + + // Window menu + let window_menu = Submenu::new( + "Window", + Menu::new() + .add_native_item(MenuItem::Minimize) + .add_native_item(MenuItem::Zoom) + .add_native_item(MenuItem::Separator) + .add_native_item(MenuItem::CloseWindow), + ); + + Menu::new() + .add_submenu(app_menu) + .add_submenu(edit_menu) + .add_submenu(view_menu) + .add_submenu(window_menu) } fn main() { @@ -138,9 +196,11 @@ fn main() { get_base_branch, // Worktree management discover_environments_with_config, + discover_environments_v2, list_worktrees, list_git_branches, check_worktree_exists, + check_environment_conflict, create_worktree, create_worktree_with_workmux, merge_worktree_with_rebase, @@ -160,6 +220,21 @@ fn main() { open_tmux_in_terminal, capture_tmux_pane, get_claude_status, + // Kanban ticket integration + create_ticket_worktree, + attach_ticket_to_worktree, + get_tickets_for_tmux_window, + get_ticket_tmux_info, + start_coding_agent_for_ticket, + // Kanban ticket/epic CRUD (local storage) + get_tickets, + get_epics, + create_ticket, + update_ticket, + delete_ticket, + create_epic, + update_epic, + delete_epic, // Settings load_launcher_settings, save_launcher_settings, @@ -170,6 +245,27 @@ fn main() { // Generic installer install_prerequisite, start_prerequisite, + // Config management (from 4bdc-ushadow-launchge) + load_project_config, + get_current_config, + check_launcher_config_exists, + validate_config_file, + // Environment scanning + scan_env_file, + scan_all_env_vars, + // Infrastructure discovery + get_infra_services_from_compose, + // Claude session monitoring + install_claude_hooks, + read_claude_sessions, + get_hooks_installed, + read_claude_transcript, + send_claude_approval, + // OAuth server + start_oauth_server, + wait_for_oauth_callback, + // HTTP client + http_request, ]) .setup(|app| { let window = app.get_window("main").unwrap(); diff --git a/ushadow/launcher/src-tauri/src/models.rs b/ushadow/launcher/src-tauri/src/models.rs index 1a4f89b8..5660d1b8 100644 --- a/ushadow/launcher/src-tauri/src/models.rs +++ b/ushadow/launcher/src-tauri/src/models.rs @@ -82,6 +82,7 @@ pub struct UshadowEnvironment { pub is_worktree: bool, // True if this environment is a git worktree pub created_at: Option, // Unix timestamp (seconds since epoch) pub base_branch: Option, // "main" or "dev" - which base branch this worktree was created from + pub project_name: Option, // Project name for multi-project support } /// Infrastructure service status @@ -143,3 +144,87 @@ pub struct ClaudeStatus { pub current_task: Option, pub last_output: Option, } + +/// Environment conflict info - when creating environment that already exists +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct EnvironmentConflict { + pub name: String, + pub current_branch: String, + pub path: String, + pub is_running: bool, +} + +/// Compose service definition from docker-compose.yml +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ComposeServiceDefinition { + pub id: String, // Service name from compose (e.g., "postgres", "redis") + pub display_name: String, // Human-readable name (e.g., "PostgreSQL", "Redis") + pub default_port: Option, // Primary exposed port + pub profiles: Vec, // Profiles this service belongs to +} + +/// Kanban ticket status +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum TicketStatus { + Backlog, + Todo, + InProgress, + InReview, + Done, + Archived, +} + +/// Kanban ticket priority +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum TicketPriority { + Low, + Medium, + High, + Urgent, +} + +/// Epic (collection of related tickets) +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Epic { + pub id: String, + pub title: String, + pub description: Option, + pub color: String, + pub branch_name: Option, + pub base_branch: String, + pub project_id: Option, + pub created_at: String, + pub updated_at: String, +} + +/// Kanban ticket +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Ticket { + pub id: String, + pub title: String, + pub description: Option, + pub status: TicketStatus, + pub priority: TicketPriority, + pub epic_id: Option, + pub tags: Vec, + pub color: Option, + pub tmux_window_name: Option, + pub tmux_session_name: Option, + pub branch_name: Option, + pub worktree_path: Option, + pub environment_name: Option, + pub project_id: Option, + pub assigned_to: Option, + pub order: i32, + pub created_at: String, + pub updated_at: String, +} + +/// Kanban data storage structure +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct KanbanData { + pub tickets: Vec, + pub epics: Vec, +} diff --git a/ushadow/launcher/src-tauri/tauri.conf.json b/ushadow/launcher/src-tauri/tauri.conf.json index ba821f18..0dad0547 100644 --- a/ushadow/launcher/src-tauri/tauri.conf.json +++ b/ushadow/launcher/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "Ushadow", - "version": "0.7.15" + "version": "0.8.22" }, "tauri": { "allowlist": { @@ -59,6 +59,20 @@ }, "notification": { "all": true + }, + "window": { + "all": false, + "create": true, + "center": true, + "close": true, + "hide": true, + "show": true, + "setFocus": true, + "setTitle": true, + "setSize": true, + "setPosition": true, + "setResizable": true, + "setAlwaysOnTop": true } }, "bundle": { @@ -80,7 +94,7 @@ "minimumSystemVersion": "10.15", "exceptionDomain": "localhost", "signingIdentity": null, - "entitlements": null + "entitlements": "entitlements.plist" }, "windows": { "webviewInstallMode": { @@ -91,11 +105,18 @@ } }, "deb": { - "depends": ["docker.io", "tailscale"] + "depends": ["docker.io | docker-ce", "tailscale"] } }, "security": { - "csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:* https://localhost:* ws://localhost:* wss://localhost:*; img-src 'self' data: http://localhost:* https://localhost:*; style-src 'self' 'unsafe-inline'; frame-src http://localhost:* https://localhost:*" + "csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:* https://localhost:* ws://localhost:* wss://localhost:*; img-src 'self' data: http://localhost:* https://localhost:*; style-src 'self' 'unsafe-inline'; frame-src http://localhost:* https://localhost:*", + "dangerousRemoteDomainIpcAccess": [ + { + "domain": "localhost", + "windows": ["main", "oauth-window"], + "enableTauriAPI": false + } + ] }, "systemTray": { "iconPath": "icons/icon.png", @@ -111,7 +132,8 @@ "minWidth": 800, "minHeight": 600, "center": true, - "visible": false + "visible": false, + "fileDropEnabled": false } ] } diff --git a/ushadow/launcher/src/App.tsx b/ushadow/launcher/src/App.tsx index b8eb562d..8739b248 100644 --- a/ushadow/launcher/src/App.tsx +++ b/ushadow/launcher/src/App.tsx @@ -1,21 +1,28 @@ import { useState, useEffect, useCallback, useRef } from 'react' -import { tauri, type Prerequisites, type Discovery, type UshadowEnvironment, type PlatformPrerequisitesConfig } from './hooks/useTauri' +import { tauri, type Prerequisites, type Discovery, type UshadowEnvironment, type PlatformPrerequisitesConfig, type EnvironmentConflict } from './hooks/useTauri' import { useAppStore, type BranchType } from './store/appStore' import { useWindowFocus } from './hooks/useWindowFocus' import { useTmuxMonitoring } from './hooks/useTmuxMonitoring' -import { writeText, readText } from '@tauri-apps/api/clipboard' import { DevToolsPanel } from './components/DevToolsPanel' import { PrerequisitesPanel } from './components/PrerequisitesPanel' import { InfrastructurePanel } from './components/InfrastructurePanel' +import { InfraConfigPanel } from './components/InfraConfigPanel' import { EnvironmentsPanel } from './components/EnvironmentsPanel' import { LogPanel, type LogEntry, type LogLevel } from './components/LogPanel' import { ProjectSetupDialog } from './components/ProjectSetupDialog' import { NewEnvironmentDialog } from './components/NewEnvironmentDialog' +import { EnvironmentConflictDialog } from './components/EnvironmentConflictDialog' import { TmuxManagerDialog } from './components/TmuxManagerDialog' import { SettingsDialog } from './components/SettingsDialog' import { EmbeddedView } from './components/EmbeddedView' -import { RefreshCw, Settings, Zap, Loader2, FolderOpen, Pencil, Terminal, Sliders, Package, FolderGit2 } from 'lucide-react' +import { ProjectManager } from './components/ProjectManager' +import { AuthButton } from './components/AuthButton' +import { RefreshCw, Settings, Zap, Loader2, FolderOpen, Pencil, Terminal, Sliders, Package, FolderGit2, Trello, Bot } from 'lucide-react' import { getColors } from './utils/colors' +import { KanbanBoard } from './components/KanbanBoard' +import { ClaudeSessionsPanel } from './components/ClaudeSessionsPanel' +import { LauncherNotch } from './components/LauncherNotch' +import { useClaudeSessions } from './hooks/useClaudeSessions' function App() { // Store @@ -33,8 +40,20 @@ function App() { setProjectRoot, worktreesDir, setWorktreesDir, + multiProjectMode, + kanbanEnabled, + claudeEnabled, + projects, + activeProjectId, } = useAppStore() + // Get active project in multi-project mode, or use legacy projectRoot + const activeProject = multiProjectMode && activeProjectId + ? projects.find(p => p.id === activeProjectId) + : null + const effectiveProjectRoot = activeProject?.rootPath || projectRoot + const effectiveWorktreesDir = activeProject?.worktreesPath || worktreesDir + // State const [platform, setPlatform] = useState('') const [prerequisites, setPrerequisites] = useState(null) @@ -45,16 +64,52 @@ function App() { const [installingItem, setInstallingItem] = useState(null) const [isLaunching, setIsLaunching] = useState(false) const [loadingInfra, setLoadingInfra] = useState(false) - const [loadingEnv, setLoadingEnv] = useState(null) + const [loadingEnv, setLoadingEnv] = useState<{ name: string; action: 'starting' | 'stopping' | 'deleting' | 'merging' } | null>(null) const [showProjectDialog, setShowProjectDialog] = useState(false) const [showNewEnvDialog, setShowNewEnvDialog] = useState(false) const [showTmuxManager, setShowTmuxManager] = useState(false) const [showSettingsDialog, setShowSettingsDialog] = useState(false) - const [embeddedView, setEmbeddedView] = useState<{ url: string; envName: string; envColor: string; envPath: string | null } | null>(null) + const [embeddedView, setEmbeddedView] = useState<{ url: string; envName: string; envColor: string; envPath: string | null; backendPort: number | null } | null>(null) const [creatingEnvs, setCreatingEnvs] = useState<{ name: string; status: 'cloning' | 'starting' | 'error'; path?: string; error?: string }[]>([]) const [shouldAutoLaunch, setShouldAutoLaunch] = useState(false) const [leftColumnWidth, setLeftColumnWidth] = useState(350) // pixels const [isResizing, setIsResizing] = useState(false) + const [environmentConflict, setEnvironmentConflict] = useState(null) + const [pendingEnvCreation, setPendingEnvCreation] = useState<{ name: string; branch: string; baseBranch?: string } | null>(null) + const [selectedEnvironment, setSelectedEnvironment] = useState(null) + + // Auto-select environment matching current directory's ENV_NAME, or first running + useEffect(() => { + if (!selectedEnvironment && discovery?.environments) { + // Try to find environment matching the current project root + const currentEnv = discovery.environments.find(e => + e.running && e.path === projectRoot + ) + // Fallback to first running environment + const envToSelect = currentEnv || discovery.environments.find(e => e.running) + if (envToSelect) { + console.log('[App] Auto-selecting environment:', { + name: envToSelect.name, + backend_port: envToSelect.backend_port, + path: envToSelect.path, + projectRoot, + matched: !!currentEnv + }) + setSelectedEnvironment(envToSelect) + } + } + }, [discovery?.environments, selectedEnvironment, projectRoot]) + + // Debug: expose selectedEnvironment to console + useEffect(() => { + if (selectedEnvironment) { + (window as any).selectedEnv = selectedEnvironment + console.log('[App] Selected environment updated:', { + name: selectedEnvironment.name, + backend_port: selectedEnvironment.backend_port + }) + } + }, [selectedEnvironment]) // Window focus detection for smart polling const isWindowFocused = useWindowFocus() @@ -63,16 +118,56 @@ function App() { const environmentNames = discovery?.environments.map(e => e.name) ?? [] const tmuxStatuses = useTmuxMonitoring(environmentNames, isWindowFocused && environmentNames.length > 0) + // Claude Code session monitoring (only polls when feature flag is enabled) + const environments = discovery?.environments ?? [] + const { sessions: claudeSessions, hooksInstalled, installing: installingHooks, error: hooksError, installSuccess: hooksInstallSuccess, installHooks } = + useClaudeSessions(effectiveProjectRoot, environments, claudeEnabled) + + // Infrastructure service selection + const [selectedInfraServices, setSelectedInfraServices] = useState([]) + + // Auto-select running infrastructure services + useEffect(() => { + if (discovery?.infrastructure) { + const runningServiceIds = discovery.infrastructure + .filter(service => service.running) + .map(service => service.name) + + // Only update if the running services have changed + setSelectedInfraServices(prev => { + const prevSet = new Set(prev) + const newSet = new Set(runningServiceIds) + + // Check if sets are different + if (prevSet.size !== newSet.size) return runningServiceIds + for (const id of runningServiceIds) { + if (!prevSet.has(id)) return runningServiceIds + } + + return prev // No change needed + }) + } + }, [discovery?.infrastructure]) + + const handleToggleInfraService = (serviceId: string) => { + setSelectedInfraServices(prev => + prev.includes(serviceId) + ? prev.filter(id => id !== serviceId) + : [...prev, serviceId] + ) + } + const logIdRef = useRef(0) const lastStateRef = useRef('') // Logging functions - const log = useCallback((message: string, level: LogLevel = 'info') => { + const log = useCallback((message: string, level: LogLevel = 'info', detailed = false) => { setLogs(prev => [...prev, { id: logIdRef.current++, timestamp: new Date(), message, level, + detailed, }]) }, []) @@ -118,85 +213,54 @@ function App() { } }, [isResizing, handleMouseMove, handleMouseUp]) - // Enable keyboard shortcuts for copy/paste - useEffect(() => { - const handleKeyDown = async (e: KeyboardEvent) => { - const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0 - const modifier = isMac ? e.metaKey : e.ctrlKey - - // Only handle copy/paste/cut if modifier key is pressed - if (!modifier) return + // Enable native clipboard operations (undo/redo/copy/paste/cut/select-all) + // Tauri webview supports standard browser clipboard API + // All native keyboard shortcuts work by default: Cmd/Ctrl+Z (undo), Cmd/Ctrl+Shift+Z (redo), Cmd/Ctrl+A (select all), etc. - // Get the active element - const target = e.target as HTMLElement - const isInputField = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable - - try { - if (e.key.toLowerCase() === 'c') { - // Copy: get selection from input field or window selection - let textToCopy = '' - if (isInputField && (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) { - const start = target.selectionStart || 0 - const end = target.selectionEnd || 0 - textToCopy = target.value.substring(start, end) - } else { - const selection = window.getSelection() - textToCopy = selection?.toString() || '' - } + // Token sharing API: Listen for token requests from environment iframes + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + // Security: Only respond to messages from embedded environments + // TODO: Add origin validation if needed + + if (event.data.type === 'GET_KC_TOKEN') { + // Get tokens from localStorage + const token = localStorage.getItem('kc_access_token') + const refreshToken = localStorage.getItem('kc_refresh_token') + const idToken = localStorage.getItem('kc_id_token') + + // Send tokens back to requesting iframe + event.source?.postMessage( + { + type: 'KC_TOKEN_RESPONSE', + tokens: { token, refreshToken, idToken }, + }, + '*' // TODO: Restrict to specific origins in production + ) + } - if (textToCopy) { - await writeText(textToCopy) - e.preventDefault() - } - } else if (e.key.toLowerCase() === 'v') { - // Paste: handle input fields specially, but allow pasting anywhere - const text = await readText() - if (!text) return - - if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { - // Paste into input/textarea - const start = target.selectionStart || 0 - const end = target.selectionEnd || 0 - const currentValue = target.value - target.value = currentValue.substring(0, start) + text + currentValue.substring(end) - target.selectionStart = target.selectionEnd = start + text.length - - // Trigger input event so React state updates - const event = new Event('input', { bubbles: true }) - target.dispatchEvent(event) - e.preventDefault() - } else if (target.isContentEditable) { - // Paste into contenteditable - document.execCommand('insertText', false, text) - e.preventDefault() - } - } else if (e.key.toLowerCase() === 'x') { - // Cut: only from input fields - if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { - const start = target.selectionStart || 0 - const end = target.selectionEnd || 0 - const selectedText = target.value.substring(start, end) - if (selectedText) { - await writeText(selectedText) - const currentValue = target.value - target.value = currentValue.substring(0, start) + currentValue.substring(end) - target.selectionStart = target.selectionEnd = start - - // Trigger input event so React state updates - const event = new Event('input', { bubbles: true }) - target.dispatchEvent(event) - e.preventDefault() - } - } - } - } catch (err) { - // Silently fail if clipboard access is denied - console.warn('Clipboard access failed:', err) + if (event.data.type === 'REFRESH_KC_TOKEN') { + // TODO: Implement token refresh logic + // For now, just return current tokens + const token = localStorage.getItem('kc_access_token') + const refreshToken = localStorage.getItem('kc_refresh_token') + const idToken = localStorage.getItem('kc_id_token') + + event.source?.postMessage( + { + type: 'KC_TOKEN_REFRESHED', + tokens: { token, refreshToken, idToken }, + }, + '*' + ) } } - document.addEventListener('keydown', handleKeyDown, true) // Use capture phase - return () => document.removeEventListener('keydown', handleKeyDown, true) + window.addEventListener('message', handleMessage) + + return () => { + window.removeEventListener('message', handleMessage) + } }, []) // Apply spoofed values to prerequisites @@ -295,22 +359,16 @@ function App() { const init = async () => { try { log('Initializing...', 'step') - console.log('[Init] Starting initialization...') const os = await tauri.getOsType() - console.log('[Init] OS:', os) setPlatform(os) log(`Platform: ${os}`) // Load prerequisites configuration for this platform - console.log('[Init] Loading prerequisites config...') const config = await tauri.getPlatformPrerequisitesConfig(os) - console.log('[Init] Prerequisites config:', config) setPrerequisitesConfig(config) - console.log('[Init] Getting default project dir...') const defaultDir = await tauri.getDefaultProjectDir() - console.log('[Init] Default dir:', defaultDir) // Track if this is first time setup (showing project dialog) let isFirstTimeSetup = false @@ -323,22 +381,18 @@ function App() { log('Please configure your repository location', 'step') } else { // Sync existing project root to Rust backend - console.log('[Init] Setting project root:', projectRoot) await tauri.setProjectRoot(projectRoot) } // Check prerequisites immediately (system-wide, no project needed) - console.log('[Init] Checking prerequisites...') await refreshPrerequisites() // Only run discovery if we have a valid project root if (!isFirstTimeSetup) { - console.log('[Init] Running discovery...') await refreshDiscovery() } log('Ready', 'success') - console.log('[Init] Initialization complete') } catch (err) { console.error('[Init] Initialization error:', err) log(`Initialization failed: ${err}`, 'error') @@ -378,6 +432,15 @@ function App() { return () => clearInterval(interval) }, [refreshPrerequisites, refreshDiscovery, isWindowFocused, projectRoot]) + // Sync active project root to Rust backend when it changes (multi-project mode) + useEffect(() => { + if (effectiveProjectRoot && effectiveProjectRoot !== projectRoot) { + tauri.setProjectRoot(effectiveProjectRoot).catch(err => { + console.error('Failed to sync project root to backend:', err) + }) + } + }, [effectiveProjectRoot, projectRoot]) + // Install handlers const handleInstall = async (item: string) => { setIsInstalling(true) @@ -503,12 +566,15 @@ function App() { log('[DRY RUN] Infrastructure start simulated', 'success') } else { const result = await tauri.startInfrastructure() - log(result, 'success') + // Log the full compose output to Detail tab only — it contains pull progress + log(result, 'info', true) + log('✓ Infrastructure started', 'success') } await refreshDiscovery() } catch (err) { log(`Failed to start infrastructure`, 'error') - log(String(err), 'error') + // Log the detailed error output to Detail tab + log(String(err), 'error', true) } finally { setLoadingInfra(false) } @@ -560,8 +626,7 @@ function App() { // Environment handlers const handleStartEnv = async (envName: string, explicitPath?: string) => { - console.log(`DEBUG: handleStartEnv called for ${envName}`) - setLoadingEnv(envName) + setLoadingEnv({ name: envName, action: 'starting' }) log(`Starting ${envName}...`, 'step') // Use explicit path if provided, otherwise look up the environment @@ -636,9 +701,7 @@ function App() { } }) } else { - console.log(`DEBUG: Calling tauri.startEnvironment(${envName}, ${envPath})`) const result = await tauri.startEnvironment(envName, envPath) - console.log(`DEBUG: tauri.startEnvironment returned: ${result}`) // Only log summary to activity log (full detail is in console/detail pane) log(`✓ Environment ${envName} started successfully`, 'success') @@ -673,7 +736,7 @@ function App() { } const handleStopEnv = async (envName: string) => { - setLoadingEnv(envName) + setLoadingEnv({ name: envName, action: 'stopping' }) log(`Stopping ${envName}...`, 'step') try { @@ -711,7 +774,7 @@ function App() { const url = env.tailscale_url || env.localhost_url || `http://localhost:${env.webui_port || env.backend_port}` const colors = getColors(env.color || env.name) log(`Opening ${env.name} in embedded view...`, 'info') - setEmbeddedView({ url, envName: env.name, envColor: colors.primary, envPath: env.path }) + setEmbeddedView({ url, envName: env.name, envColor: colors.primary, envPath: env.path, backendPort: env.backend_port }) } const handleMerge = async (envName: string) => { @@ -728,7 +791,7 @@ function App() { if (!confirmed) return - setLoadingEnv(envName) + setLoadingEnv({ name: envName, action: 'merging' }) log(`Merging worktree "${envName}" to main...`, 'step') try { @@ -776,24 +839,24 @@ function App() { ? `Delete environment "${envName}"?\n\n` + `This will:\n` + `• Stop all containers\n` + - `• Remove the worktree\n` + + `• Remove the worktree (including any uncommitted changes)\n` + `• Close the tmux session\n\n` + - `This action cannot be undone!` + `⚠️ This action cannot be undone!` : `Delete environment "${envName}"?\n\n` + `This will:\n` + `• Stop all containers\n` + `• Close the tmux session\n\n` + - `This action cannot be undone!` + `⚠️ This action cannot be undone!` const confirmed = window.confirm(message) if (!confirmed) return - setLoadingEnv(envName) + setLoadingEnv({ name: envName, action: 'deleting' }) log(`Deleting environment "${envName}"...`, 'step') try { - const result = await tauri.deleteEnvironment(projectRoot, envName) + const result = await tauri.deleteEnvironment(effectiveProjectRoot, envName) log(result, 'success') log(`✓ Environment "${envName}" deleted`, 'success') @@ -832,7 +895,7 @@ function App() { // Force lowercase to avoid Docker Compose naming issues name = name.toLowerCase() - const envPath = `${projectRoot}/../${name}` // Expected clone location + const envPath = `${effectiveProjectRoot}/../${name}` // Expected clone location const modeLabel = serverMode === 'dev' ? 'hot reload' : 'production' // Check port availability in dev mode (non-quick launch) @@ -889,17 +952,18 @@ function App() { name = name.toLowerCase() branch = branch.toLowerCase() - if (!worktreesDir) { + if (!effectiveWorktreesDir) { log('Worktrees directory not configured', 'error') throw new Error('Worktrees directory not configured') } log(`Creating worktree "${name}" from branch "${branch}"...`, 'step') - log(`Project root: ${projectRoot}`, 'info') - log(`Worktrees dir: ${worktreesDir}`, 'info') + log(`Project root: ${effectiveProjectRoot}`, 'info') + log(`Worktrees dir: ${effectiveWorktreesDir}`, 'info') + log(`Base branch: ${baseBranch || 'auto-detect from suffix'}`, 'info') try { - const worktree = await tauri.createWorktreeWithWorkmux(projectRoot, name, branch, true) + const worktree = await tauri.createWorktreeWithWorkmux(effectiveProjectRoot, name, branch, baseBranch, true, undefined) log(`✓ Worktree created successfully`, 'success') log(`Path: ${worktree.path}`, 'info') log(`Branch: ${worktree.branch}`, 'info') @@ -930,19 +994,38 @@ function App() { } } - const handleNewEnvWorktree = async (name: string, branch: string) => { + const handleNewEnvWorktree = async (name: string, branch: string, baseBranch?: string) => { setShowNewEnvDialog(false) // Force lowercase to avoid Docker Compose naming issues name = name.toLowerCase() branch = branch.toLowerCase() + baseBranch = baseBranch?.toLowerCase() - if (!worktreesDir) { + if (!effectiveWorktreesDir) { log('Worktrees directory not configured', 'error') return } - const envPath = `${worktreesDir}/${name}` + // Check for conflicts first + try { + const conflict = await tauri.checkEnvironmentConflict(effectiveProjectRoot, name) + if (conflict) { + // Check if the environment is actually running (from discovery data) + const env = discovery?.environments.find(e => e.name === name) + conflict.is_running = env?.running || false + + // Show conflict dialog + setEnvironmentConflict(conflict) + setPendingEnvCreation({ name, branch, baseBranch }) + return + } + } catch (err) { + log(`Failed to check for conflicts: ${err}`, 'warning') + // Continue anyway + } + + const envPath = `${effectiveWorktreesDir}/${name}` // Add to creating environments list setCreatingEnvs(prev => [...prev, { name, status: 'cloning', path: envPath }]) @@ -957,7 +1040,7 @@ function App() { } else { // Step 1: Create the git worktree with workmux (includes tmux integration) log(`Creating git worktree at ${envPath}...`, 'info') - const worktree = await tauri.createWorktreeWithWorkmux(projectRoot, name, branch || undefined, true) + const worktree = await tauri.createWorktreeWithWorkmux(effectiveProjectRoot, name, branch || undefined, baseBranch, true, undefined) log(`✓ Worktree created at ${worktree.path}`, 'success') // Step 1.5: Write default admin credentials if configured @@ -1015,6 +1098,120 @@ function App() { } } + // Conflict resolution handlers + const handleConflictStartExisting = async () => { + if (!environmentConflict) return + + setEnvironmentConflict(null) + setPendingEnvCreation(null) + log(`Starting existing environment "${environmentConflict.name}"...`, 'step') + + // Start the existing environment + await handleStartEnv(environmentConflict.name, environmentConflict.path) + } + + const handleConflictSwitchBranch = async () => { + if (!environmentConflict || !pendingEnvCreation) return + + const { name, branch, baseBranch } = pendingEnvCreation + setEnvironmentConflict(null) + setPendingEnvCreation(null) + + log(`Switching "${name}" to branch "${branch}"...`, 'step') + + try { + // Stop if running + if (environmentConflict.is_running) { + log('Stopping environment before switching branch...', 'info') + await tauri.stopEnvironment(name) + } + + // Checkout the new branch + log(`Checking out branch ${branch}...`, 'info') + await tauri.checkoutBranch(environmentConflict.path, branch) + log(`✓ Switched to ${branch}`, 'success') + + // Start the environment + await handleStartEnv(name, environmentConflict.path) + } catch (err) { + log(`Failed to switch branch: ${err}`, 'error') + } + } + + const handleConflictDeleteAndRecreate = async () => { + if (!environmentConflict || !pendingEnvCreation) return + + const { name, branch, baseBranch } = pendingEnvCreation + setEnvironmentConflict(null) + setPendingEnvCreation(null) + + log(`Deleting and recreating "${name}"...`, 'step') + + try { + // Delete the old environment (stops containers, removes worktree, closes tmux) + await tauri.deleteEnvironment(effectiveProjectRoot, name) + log(`✓ Old environment deleted`, 'success') + + // Wait a moment for cleanup + await new Promise(r => setTimeout(r, 1000)) + + // Now create the new environment (reuse existing logic from handleNewEnvWorktree) + const envPath = `${effectiveWorktreesDir}/${name}` + setCreatingEnvs(prev => [...prev, { name, status: 'cloning', path: envPath }]) + log(`Creating worktree "${name}" from branch "${branch}"...`, 'step') + + if (dryRunMode) { + log(`[DRY RUN] Would create worktree "${name}" for branch "${branch}"`, 'warning') + setCreatingEnvs(prev => prev.map(e => e.name === name ? { ...e, status: 'starting' } : e)) + await new Promise(r => setTimeout(r, 2000)) + log(`[DRY RUN] Worktree environment "${name}" created`, 'success') + } else { + log(`Creating git worktree at ${envPath}...`, 'info') + const worktree = await tauri.createWorktreeWithWorkmux(effectiveProjectRoot, name, branch || undefined, baseBranch, true, undefined) + log(`✓ Worktree created at ${worktree.path}`, 'success') + + // Write credentials if configured + try { + const settings = await tauri.loadLauncherSettings() + if (settings.default_admin_email && settings.default_admin_password) { + log(`Writing admin credentials to secrets.yaml...`, 'info') + await tauri.writeCredentialsToWorktree( + worktree.path, + settings.default_admin_email, + settings.default_admin_password, + settings.default_admin_name || undefined + ) + log(`✓ Admin credentials configured`, 'success') + } + } catch (err) { + log(`Could not write credentials: ${err}`, 'warning') + } + + setCreatingEnvs(prev => prev.map(e => e.name === name ? { ...e, status: 'starting', path: worktree.path } : e)) + + // Start the environment + log(`Starting environment "${name}"...`, 'step') + await handleStartEnv(name, worktree.path) + + log(`✓ Worktree environment "${name}" created and started!`, 'success') + } + + setTimeout(() => { + setCreatingEnvs(prev => prev.filter(e => e.name !== name)) + }, 15000) + + await refreshDiscovery() + } catch (err) { + log(`Failed to delete and recreate: ${err}`, 'error') + setCreatingEnvs(prev => prev.map(e => e.name === name ? { ...e, status: 'error', error: String(err) } : e)) + } + } + + const handleConflictCancel = () => { + setEnvironmentConflict(null) + setPendingEnvCreation(null) + } + // Project setup handler - saves paths, doesn't clone yet const handleProjectSetup = async (path: string, worktreesPath: string) => { setShowProjectDialog(false) @@ -1053,12 +1250,8 @@ function App() { const handleClone = async (path: string, branch?: string) => { try { - console.log('DEBUG handleClone: Starting clone for path:', path, 'branch:', branch) - console.log('DEBUG handleClone: dryRunMode =', dryRunMode) - // Check if repo already exists at this location const status = await tauri.checkProjectDir(path) - console.log('DEBUG handleClone: checkProjectDir status =', status) if (status.exists && status.is_valid_repo) { // Repo exists - pull latest instead of cloning @@ -1083,9 +1276,7 @@ function App() { await new Promise(r => setTimeout(r, 2000)) log('[DRY RUN] Clone simulated', 'success') } else { - console.log('DEBUG handleClone: Calling tauri.cloneUshadowRepo with path:', path, 'branch:', branch) const result = await tauri.cloneUshadowRepo(path, branch) - console.log('DEBUG handleClone: Clone result from Rust:', result) log(result, 'success') } } @@ -1093,7 +1284,7 @@ function App() { await tauri.setProjectRoot(path) setProjectRoot(path) } catch (err) { - log(`Clone failed: ${err}`, 'error') + log(`${err}`, 'error') throw err } } @@ -1120,24 +1311,12 @@ function App() { // Quick launch (for quick mode) const handleQuickLaunch = async () => { - console.log('DEBUG: handleQuickLaunch started') setIsLaunching(true) setLogExpanded(true) log('🚀 Starting Ushadow quick launch...', 'step') - // Check if we need to install prereqs or start infra - const prereqs = getEffectivePrereqs(prerequisites) - const needsPrereqs = !prereqs?.git_installed || !prereqs?.python_installed || !prereqs?.docker_installed || !prereqs?.docker_running - const needsInfra = !discovery || !discovery.docker_ok || discovery.infrastructure.some(svc => !svc.running) - - // If prereqs or infra need setup, switch to infra page first - if (needsPrereqs || needsInfra) { - setAppMode('infra') - log('Installing prerequisites and starting infrastructure...', 'step') - } else { - // Everything ready, go straight to environments - setAppMode('environments') - } + // Always navigate to infra page so the user can see what's happening + setAppMode('infra') // Give UI a brief moment to render await new Promise(r => setTimeout(r, 50)) @@ -1273,35 +1452,43 @@ function App() { if (infraRunning) { log('✓ Infrastructure already running', 'success') } else { - log('Starting infrastructure...', 'step') - if (dryRunMode) { - log('[DRY RUN] Would start infrastructure', 'warning') - await new Promise(r => setTimeout(r, 1000)) - log('[DRY RUN] Infrastructure started', 'success') - } else { - const infraResult = await tauri.startInfrastructure() - log(infraResult, 'success') + log('Starting infrastructure (pulling images if needed)...', 'step') + setLoadingInfra(true) + try { + if (dryRunMode) { + log('[DRY RUN] Would start infrastructure', 'warning') + await new Promise(r => setTimeout(r, 1000)) + log('[DRY RUN] Infrastructure started', 'success') + } else { + const infraResult = await tauri.startInfrastructure() + // Full compose output (including pull progress) goes to Detail tab + log(infraResult, 'info', true) + log('✓ Infrastructure started', 'success') + } + } finally { + setLoadingInfra(false) } } } await refreshDiscovery() + // Switch to environments page before starting env — user sees the "creating" card immediately + setAppMode('environments') + // Step 7: Start the ushadow environment log(`Starting ushadow environment...`, 'step') await handleStartEnv('ushadow', undefined) - // Switch to environments page after setup is complete - setAppMode('environments') - if (failedInstalls.length > 0) { log('Quick launch completed with warnings', 'warning') } else { log('Quick launch complete!', 'success') } } catch (err) { - console.error('DEBUG: handleQuickLaunch caught error:', err) - log(`Quick launch failed: ${err}`, 'error') - // On error, also switch to environments page to show the error + const errMsg = String(err) + // If the error already has a clear message (e.g. network error), show it as-is + const isVerbose = errMsg.startsWith('Network error:') || errMsg.startsWith('Permission denied') || errMsg.startsWith('Git clone failed:') + log(isVerbose ? errMsg : `Quick launch failed: ${errMsg}`, 'error') setAppMode('environments') } finally { setIsLaunching(false) @@ -1323,6 +1510,7 @@ function App() { envName={embeddedView.envName} envColor={embeddedView.envColor} envPath={embeddedView.envPath} + backendPort={embeddedView.backendPort} onClose={() => setEmbeddedView(null)} /> )} @@ -1347,7 +1535,7 @@ function App() { + {kanbanEnabled && ( + + )} + {claudeEnabled && ( + + )}
{/* Tmux Manager */} @@ -1386,15 +1603,21 @@ function App() { - {/* Settings / Credentials Button */} + {/* Auth Button */} + + + {/* Settings Button */} {/* Refresh */} @@ -1410,171 +1633,176 @@ function App() { {/* Main Content */} -
- {appMode === 'install' ? ( - /* Install Page - One-Click Launch (Landing Page) */ -
-
-

One-Click Launch

-

- Automatically install prerequisites and start Ushadow -

-
+
+ {/* Install Page — always mounted for instant tab switch */} +
+ {multiProjectMode ? ( + /* Multi-Project Mode - Project Manager */ + <> +
+

Project Management

+

+ Manage multiple projects with independent configurations +

+
+
+ +
+ + ) : ( + /* Single-Project Mode - One-Click Launch */ +
+
+

One-Click Launch

+

+ Automatically install prerequisites and start Ushadow +

+
+ + {/* Project Folder Display */} +
+ + Project folder: + + {projectRoot || 'Not set'} + + +
- {/* Project Folder Display */} -
- - Project folder: - - {projectRoot || 'Not set'} -
+ )} +
- + {/* Infra Page — always mounted for instant tab switch */} +
+
+

Setup & Installation

+

+ Install prerequisites and configure shared infrastructure +

- ) : appMode === 'infra' ? ( - /* Infra Page - Prerequisites & Infrastructure Setup */ -
-
-

Setup & Installation

-

- Install prerequisites and configure your single environment -

-
- - {/* Prerequisites and Infrastructure Side-by-Side */} -
-
- setShowDevTools(!showDevTools)} - /> - {/* Dev Tools Panel - appears below Prerequisites */} - {showDevTools && } -
- +
+ setShowDevTools(!showDevTools)} /> + {showDevTools && }
- {/* Single Environment Section for Consumers */} -
-

Your Environment

- {!discovery || discovery.environments.length === 0 ? ( -
-

No environment created yet

- -
- ) : ( -
- {discovery.environments.map(env => ( -
-
-
- {env.name} - - {env.status} - -
-
- {env.running ? ( - <> - - - - ) : ( - - )} -
-
- ))} -
- )} -
-
- ) : ( - /* Environments Page - Worktree Management */ -
- setShowNewEnvDialog(true)} - onOpenInApp={handleOpenInApp} - onMerge={handleMerge} - onDelete={handleDelete} - onAttachTmux={handleAttachTmux} - onDismissError={(name) => setCreatingEnvs(prev => prev.filter(e => e.name !== name))} - loadingEnv={loadingEnv} - tmuxStatuses={tmuxStatuses} + refreshDiscovery(true)} + isLoading={loadingInfra} + selectedServices={selectedInfraServices} + onToggleService={handleToggleInfraService} + projectRoot={effectiveProjectRoot} />
- )} + + {multiProjectMode && effectiveProjectRoot && ( + {}} + /> + )} +
+ + {/* Environments Page — always mounted for instant tab switch */} +
+ setShowNewEnvDialog(true)} + onOpenInApp={handleOpenInApp} + onMerge={handleMerge} + onDelete={handleDelete} + onAttachTmux={handleAttachTmux} + onDismissError={(name) => setCreatingEnvs(prev => prev.filter(e => e.name !== name))} + loadingEnv={loadingEnv} + tmuxStatuses={tmuxStatuses} + selectedEnvironment={selectedEnvironment} + onSelectEnvironment={(env) => { + setSelectedEnvironment(env) + }} + /> +
+ + {appMode === 'kanban' && kanbanEnabled ? ( + /* Kanban Page - Ticket Management */ + (() => { + // Use the first available backend (running or not) + const backendUrl = discovery?.environments.find(e => e.running)?.localhost_url + || discovery?.environments[0]?.localhost_url + || 'http://localhost:8000' + + return ( + + ) + })() + ) : appMode === 'claude' && claudeEnabled ? ( + /* Claude Sessions Page */ + { + if (env.localhost_url) { + setEmbeddedView({ url: env.localhost_url, envName: env.name, envColor: env.color ?? env.name, envPath: env.path ?? null, backendPort: env.backend_port }) + } + }} + /> + ) : null}
{/* Log Panel - Bottom */} @@ -1601,7 +1829,7 @@ function App() { {/* New Environment Dialog */} setShowNewEnvDialog(false)} onLink={handleNewEnvLink} onWorktree={handleNewEnvWorktree} @@ -1619,6 +1847,28 @@ function App() { isOpen={showSettingsDialog} onClose={() => setShowSettingsDialog(false)} /> + + {/* Environment Conflict Dialog */} + + + {/* Claude Session Notch Overlay */} + {claudeEnabled && ( + { + if (env.localhost_url) { + setEmbeddedView({ url: env.localhost_url, envName: env.name, envColor: env.color ?? env.name, envPath: env.path ?? null, backendPort: env.backend_port }) + } + }} + /> + )}
) } diff --git a/ushadow/launcher/src/components/AuthButton.tsx b/ushadow/launcher/src/components/AuthButton.tsx new file mode 100644 index 00000000..9d8b4209 --- /dev/null +++ b/ushadow/launcher/src/components/AuthButton.tsx @@ -0,0 +1,293 @@ +import { useState, useEffect } from 'react' +import { LogIn, LogOut, User, Loader2 } from 'lucide-react' +import { tauri, type UshadowEnvironment } from '../hooks/useTauri' +import { TokenManager } from '../services/tokenManager' +import { generateCodeVerifier, generateCodeChallenge, generateState } from '../utils/pkce' + +interface AuthButtonProps { + // Optional: Pass specific environment to auth against + // If not provided, will use first running environment + environment?: UshadowEnvironment | null + // Show as large button in center of page (for login prompt) + variant?: 'header' | 'centered' +} + +export function AuthButton({ environment, variant = 'header' }: AuthButtonProps) { + const [isAuthenticated, setIsAuthenticated] = useState(false) + const [username, setUsername] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + // Check auth status when environment changes + useEffect(() => { + if (!environment) { + setIsLoading(false) + return + } + + checkAuthStatus() + + // Periodically check for token expiration (every 30 seconds) + const intervalId = setInterval(() => { + checkAuthStatus() + }, 30000) + + return () => clearInterval(intervalId) + }, [environment]) + + const checkAuthStatus = () => { + if (TokenManager.isAuthenticated()) { + const userInfo = TokenManager.getUserInfo() + setIsAuthenticated(true) + setUsername(userInfo?.preferred_username || userInfo?.email || 'User') + } else { + setIsAuthenticated(false) + } + setIsLoading(false) + } + + const handleLogin = async () => { + if (!environment) { + alert('No environment selected. Please start an environment first.') + return + } + + setIsLoading(true) + + try { + console.log('[AuthButton] Starting OAuth flow with HTTP callback server...') + + // Get backend URL from environment + const backendUrl = `http://localhost:${environment.backend_port}` + console.log('[AuthButton] Backend URL:', backendUrl) + + // Declare variables at function scope + let casdoorUrl: string + let clientId: string + let port: number + let callbackUrl: string + + // Fetch Casdoor config from backend (using Tauri HTTP client to bypass CORS) + console.log('[AuthButton] Fetching Casdoor config from backend...') + const configResponse = await tauri.httpRequest(`${backendUrl}/api/settings/config`, 'GET') + console.log('[AuthButton] Config response status:', configResponse.status) + if (configResponse.status !== 200) { + throw new Error(`Failed to fetch config from backend: ${configResponse.status} - ${configResponse.body}`) + } + const config = JSON.parse(configResponse.body) + casdoorUrl = config.casdoor?.public_url || 'http://localhost:8082' + clientId = config.casdoor?.client_id || 'ushadow' + console.log('[AuthButton] Using Casdoor URL:', casdoorUrl) + + // Start OAuth callback server + console.log('[AuthButton] Starting OAuth callback server...') + ;[port, callbackUrl] = await tauri.startOAuthServer() + console.log('[AuthButton] ✓ Callback server running on port:', port) + console.log('[AuthButton] Callback URL:', callbackUrl) + + // Register callback URL with backend (which registers with Casdoor) + console.log('[AuthButton] Registering callback URL...') + const registerResponse = await tauri.httpRequest( + `${backendUrl}/api/auth/register-redirect-uri`, + 'POST', + { 'Content-Type': 'application/json' }, + JSON.stringify({ redirect_uri: callbackUrl }) + ) + + console.log('[AuthButton] Register response status:', registerResponse.status) + if (registerResponse.status !== 200) { + throw new Error(`Failed to register callback URL: ${registerResponse.status} - ${registerResponse.body}`) + } + console.log('[AuthButton] ✓ Callback URL registered') + + // Generate PKCE parameters + const codeVerifier = generateCodeVerifier() + const codeChallenge = await generateCodeChallenge(codeVerifier) + const state = generateState() + + // Store for callback validation + localStorage.setItem('pkce_code_verifier', codeVerifier) + localStorage.setItem('oauth_state', state) + localStorage.setItem('oauth_backend_url', backendUrl) + + // Build Casdoor authorization URL + const authUrl = new URL(`${casdoorUrl}/login/oauth/authorize`) + authUrl.searchParams.set('client_id', clientId) + authUrl.searchParams.set('redirect_uri', callbackUrl) + authUrl.searchParams.set('response_type', 'code') + authUrl.searchParams.set('scope', 'openid profile email') + authUrl.searchParams.set('state', state) + authUrl.searchParams.set('code_challenge', codeChallenge) + authUrl.searchParams.set('code_challenge_method', 'S256') + + // Open system browser + console.log('[AuthButton] Opening system browser...') + await tauri.openBrowser(authUrl.toString()) + console.log('[AuthButton] ✓ Browser opened, waiting for callback...') + + // Wait for OAuth callback (this will block until callback or timeout) + const result = await tauri.waitForOAuthCallback(port) + + if (!result.success || !result.code || !result.state) { + throw new Error(result.error || 'Login failed or cancelled') + } + + console.log('[AuthButton] ✓ Callback received') + + // Validate state (CSRF protection) + const savedState = localStorage.getItem('oauth_state') + if (result.state !== savedState) { + throw new Error('Invalid state parameter - possible CSRF attack') + } + + // Exchange code for tokens + const savedCodeVerifier = localStorage.getItem('pkce_code_verifier') + if (!savedCodeVerifier) { + throw new Error('Missing PKCE code verifier') + } + + console.log('[AuthButton] Exchanging code for tokens...') + const tokenResponse = await tauri.httpRequest( + `${backendUrl}/api/auth/token`, + 'POST', + { 'Content-Type': 'application/json' }, + JSON.stringify({ + code: result.code, + code_verifier: savedCodeVerifier, + redirect_uri: callbackUrl, + }) + ) + + if (tokenResponse.status !== 200) { + throw new Error(`Token exchange failed: ${tokenResponse.body}`) + } + + const tokens = JSON.parse(tokenResponse.body) + + // Store tokens + TokenManager.storeTokens(tokens) + console.log('[AuthButton] ✓ Login successful') + + // Clean up + localStorage.removeItem('oauth_state') + localStorage.removeItem('pkce_code_verifier') + localStorage.removeItem('oauth_backend_url') + + // Refresh the embedded environment view + const iframe = document.getElementById('embedded-iframe') as HTMLIFrameElement + if (iframe) { + console.log('[AuthButton] Refreshing environment view...') + iframe.src = iframe.src // Reload to pick up new tokens + } + + // Update UI + checkAuthStatus() + } catch (error) { + console.error('[AuthButton] Login error:', error) + alert(`Login failed: ${error}`) + setIsLoading(false) + } + } + + const handleLogout = async () => { + TokenManager.clearTokens() + setIsAuthenticated(false) + setUsername(null) + + // Optionally open Casdoor logout page + if (environment) { + try { + const backendUrl = `http://localhost:${environment.backend_port}` + const configResponse = await tauri.httpRequest(`${backendUrl}/api/settings/config`, 'GET') + if (configResponse.status === 200) { + const config = JSON.parse(configResponse.body) + const casdoorUrl = config.casdoor?.public_url || 'http://localhost:8082' + const logoutUrl = `${casdoorUrl}/login/oauth/logout` + await tauri.openBrowser(logoutUrl) + } + } catch (error) { + console.error('[AuthButton] Logout error:', error) + } + } + } + + // Don't show button if no environment + if (!environment) { + return null + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (isAuthenticated) { + if (variant === 'centered') { + return null // Don't show anything when authenticated in centered mode + } + + return ( +
+
+ + {username} +
+ +
+ ) + } + + // Centered variant - large button in middle of page + if (variant === 'centered') { + return ( +
+
+

Authentication Required

+

+ You need to log in to access Ushadow environments. +

+

+ Click below to open the login page in your browser. +

+
+ + + + {environment && ( +

+ Environment: {environment.name} • + Port: {environment.webui_port} +

+ )} +
+ ) + } + + // Header variant - compact button + return ( + + ) +} diff --git a/ushadow/launcher/src/components/ClaudeSessionsPanel.tsx b/ushadow/launcher/src/components/ClaudeSessionsPanel.tsx new file mode 100644 index 00000000..33769deb --- /dev/null +++ b/ushadow/launcher/src/components/ClaudeSessionsPanel.tsx @@ -0,0 +1,750 @@ +import { useState, useEffect, useRef } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { + Bot, Download, Code2, AppWindow, Clock, Zap, CheckCircle, AlertCircle, + Loader2, MessageSquare, RefreshCw, ShieldQuestion, Minimize2, + Ticket as TicketIcon, ArrowUpDown, ThumbsUp, ThumbsDown, Terminal, +} from 'lucide-react' +import { getColors } from '../utils/colors' +import type { ClaudeSession, ClaudeSessionStatus } from '../hooks/useClaudeSessions' +import type { ClaudeSessionEvent, TranscriptMessage, Ticket, UshadowEnvironment } from '../hooks/useTauri' +import { tauri } from '../hooks/useTauri' + +interface ClaudeSessionsPanelProps { + sessions: ClaudeSession[] + hooksInstalled: boolean | null + installing: boolean + error: string | null + installSuccess: string | null + onInstallHooks: () => void + environments: UshadowEnvironment[] + onOpenInApp: (env: UshadowEnvironment) => void +} + +// ─── helpers ──────────────────────────────────────────────────────────────── + +function statusIcon(status: ClaudeSessionStatus, className = 'w-3 h-3') { + switch (status) { + case 'Working': return + case 'Processing': return + case 'WaitingForInput': return + case 'WaitingForApproval': return + case 'Compacting': return + case 'Ended': return + default: return + } +} + +function statusLabel(status: ClaudeSessionStatus): string { + switch (status) { + case 'Working': return 'Working' + case 'Processing': return 'Processing' + case 'WaitingForInput': return 'Waiting' + case 'WaitingForApproval': return 'Needs approval' + case 'Compacting': return 'Compacting' + case 'Ended': return 'Ended' + } +} + +function formatAge(iso: string): string { + const ms = Date.now() - new Date(iso).getTime() + if (ms < 60_000) return `${Math.round(ms / 1000)}s` + if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m` + return `${Math.round(ms / 3_600_000)}h` +} + +function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +} + +const TICKET_STATUS_STYLE: Record = { + backlog: 'text-text-muted/60 bg-white/5', + todo: 'text-blue-400/80 bg-blue-400/10', + in_progress: 'text-yellow-400/90 bg-yellow-400/10', + in_review: 'text-purple-400/90 bg-purple-400/10', + done: 'text-green-400/80 bg-green-400/10', + archived: 'text-text-muted/30 bg-white/3', +} + +// ─── EventRow ──────────────────────────────────────────────────────────────── + +function EventRow({ event }: { event: ClaudeSessionEvent }) { + const data = event.data as { + tool?: string; description?: string; path?: string; message?: string + last_message?: string; compaction_type?: string + } + + let label = event.event_type + let detail = '' + let labelColor = 'text-text-muted' + + switch (event.event_type) { + case 'UserPromptSubmit': label = 'You'; detail = data.message ?? ''; labelColor = 'text-primary-400'; break + case 'PreToolUse': label = data.tool ?? 'Tool'; detail = data.description || data.path || ''; labelColor = 'text-yellow-400/80'; break + case 'PostToolUse': label = `✓ ${data.tool ?? 'Tool'}`; labelColor = 'text-green-400/60'; break + case 'Notification': label = 'Notice'; detail = data.message ?? ''; labelColor = 'text-blue-400/80'; break + case 'Stop': label = 'Done'; detail = data.last_message ?? ''; labelColor = 'text-text-muted'; break + case 'SubagentStop': label = 'Agent done'; labelColor = 'text-text-muted'; break + case 'SessionStart': label = 'Started'; labelColor = 'text-cyan-400/60'; break + case 'SessionEnd': label = 'Ended'; labelColor = 'text-text-muted/40'; break + case 'PreCompact': label = 'Compacting'; detail = data.compaction_type ?? ''; labelColor = 'text-purple-400/80'; break + } + + const isToolEvent = event.event_type === 'PreToolUse' || event.event_type === 'PostToolUse' + + return ( +
+ {isToolEvent && detail ? ( +
+ {label} + {detail} +
+ ) : ( + <> + {label} + {detail && {detail}} + + )} + {formatTime(event.timestamp)} +
+ ) +} + +// ─── TranscriptRow ─────────────────────────────────────────────────────────── + +const mdComponents: React.ComponentProps['components'] = { + p: ({ children }) =>

{children}

, + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + blockquote: ({ children }) =>
    {children}
    , + hr: () =>
    , + a: ({ href, children }) => {children}, + code: ({ children, className }) => { + const isBlock = className?.startsWith('language-') + return isBlock + ? {children} + : {children} + }, + pre: ({ children }) => ( +
    +      {children}
    +    
    + ), +} + +function TranscriptRow({ msg }: { msg: TranscriptMessage }) { + if (msg.role === 'user') { + return ( +
    +
    +

    {msg.text}

    +
    +
    + ) + } + + return ( +
    + +
    + {msg.text && ( + + {msg.text} + + )} + {msg.tools.length > 0 && ( +
    + {msg.tools.slice(0, 4).map((tool, i) => ( + + {tool.name} + {(tool.description || tool.path) && ( + + {(tool.description ?? tool.path ?? '').slice(0, 25)} + + )} + + ))} + {msg.tools.length > 4 && ( + +{msg.tools.length - 4} + )} +
    + )} +
    +
    + ) +} + +// ─── TicketCard ────────────────────────────────────────────────────────────── + +function TicketCard({ ticket }: { ticket: Ticket }) { + const statusStyle = TICKET_STATUS_STYLE[ticket.status] ?? 'text-text-muted/60 bg-white/5' + const statusText = ticket.status.replace('_', ' ') + + return ( +
    +
    + +
    +

    {ticket.title}

    +
    + + {statusText} + + {ticket.branch_name && ( + + {ticket.branch_name} + + )} + {ticket.tags.slice(0, 2).map(tag => ( + {tag} + ))} +
    +
    +
    +
    + ) +} + +// ─── SessionDetail (right column) ─────────────────────────────────────────── + +function SessionDetail({ + session, + tickets, + onOpenInApp, +}: { + session: ClaudeSession + tickets: Ticket[] + onOpenInApp: (env: UshadowEnvironment) => void +}) { + const [transcript, setTranscript] = useState(null) + const [loadingTranscript, setLoadingTranscript] = useState(false) + const transcriptBottomRef = useRef(null) + + const env = session.environment + const colors = getColors(env?.color ?? env?.name ?? session.cwd.split('/').pop() ?? 'default') + + // Reset transcript when session changes + useEffect(() => { + setTranscript(null) + setLoadingTranscript(false) + }, [session.session_id]) + + // Poll transcript for active sessions + useEffect(() => { + const fetch = () => { + tauri.readClaudeTranscript(session.session_id, session.cwd) + .then(msgs => { setTranscript(msgs); setLoadingTranscript(false) }) + .catch(() => { setTranscript([]); setLoadingTranscript(false) }) + } + + setLoadingTranscript(true) + fetch() + + if (session.status === 'Ended') return + + const interval = setInterval(fetch, 3000) + return () => clearInterval(interval) + }, [session.session_id, session.cwd, session.status]) // eslint-disable-line react-hooks/exhaustive-deps + + // Auto-scroll to bottom when transcript updates + useEffect(() => { + transcriptBottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [transcript]) + + const [approvalPending, setApprovalPending] = useState(false) + const [approvalError, setApprovalError] = useState(null) + const [approvedLocally, setApprovedLocally] = useState(false) + + // Reset approval state when switching sessions + useEffect(() => { + setApprovedLocally(false) + setApprovalError(null) + }, [session.session_id]) + + const handleApprove = async (approve: boolean) => { + setApprovalPending(true) + setApprovalError(null) + try { + await tauri.sendClaudeApproval(session.cwd, approve) + setApprovedLocally(true) // dismiss banner optimistically on success + } catch (e) { + setApprovalError(String(e)) + } finally { + setApprovalPending(false) + } + } + + const handleOpenVscode = (e: React.MouseEvent) => { + e.stopPropagation() + if (env?.path) tauri.openInVscode(env.path, env.name) + } + + const handleOpenApp = (e: React.MouseEvent) => { + e.stopPropagation() + if (env) onOpenInApp(env) + } + + return ( +
    + {/* Header */} +
    +
    + + {env?.name ?? session.cwd.split('/').slice(-2).join('/')} + + + {session.cwd.split('/').slice(-2).join('/')} + +
    + + {/* Approval banner */} + {session.status === 'WaitingForApproval' && !approvedLocally && ( +
    +
    + +
    +

    Waiting for your approval

    + {session.current_tool && ( +

    + {session.current_tool} + {session.current_tool_description && ( + {session.current_tool_description} + )} + {!session.current_tool_description && session.current_tool_path && ( + {session.current_tool_path.split('/').slice(-2).join('/')} + )} +

    + )} +
    +
    +
    + + + {env?.path && ( + + )} +
    + {approvalError && ( +
    +

    {approvalError}

    + +
    + )} +
    + )} + + {/* Kanban tickets */} + {tickets.length > 0 && ( +
    +

    Tickets

    +
    + {tickets.map(t => )} +
    +
    + )} + + {/* Conversation */} +
    +

    Conversation

    + {loadingTranscript && ( +
    + + Loading… +
    + )} + {!loadingTranscript && transcript !== null && transcript.length === 0 && ( +
    + {[...session.events].reverse().slice(0, 10).map((evt, i) => ( + + ))} +
    + )} + {!loadingTranscript && transcript && transcript.length > 0 && ( +
    + {transcript.map(msg => )} +
    +
    + )} +
    + + {/* Action buttons */} +
    + {env?.path && ( + + )} + {env?.running && ( + + )} +
    +
    + ) +} + +// ─── SessionTile (left column) ─────────────────────────────────────────────── + +function SessionTile({ + session, + selected, + onSelect, +}: { + session: ClaudeSession + selected: boolean + onSelect: () => void +}) { + const env = session.environment + const colors = getColors(env?.color ?? env?.name ?? session.cwd.split('/').pop() ?? 'default') + const isEnded = session.status === 'Ended' + + return ( +
    +
    + {/* Name + status row */} +
    +
    + + {env?.name ?? session.cwd.split('/').slice(-1)[0]} + +
    + {statusIcon(session.status)} + {statusLabel(session.status)} +
    +
    + + {formatAge(session.last_event_at)} +
    +
    + + {/* Task preview */} + {session.user_message && session.status !== 'Ended' && ( +

    + {session.user_message} +

    + )} + + {/* Current tool */} + {session.current_tool && session.status === 'Working' && ( +

    + {session.current_tool} + {session.current_tool_description && ( + + {session.current_tool_description.slice(0, 40)} + + )} +

    + )} + + {session.status === 'WaitingForApproval' && ( +
    +

    + ⚠ Approve{session.current_tool ? `: ${session.current_tool}` : ''} + {session.current_tool_description && ( + {session.current_tool_description.slice(0, 40)} + )} +

    +
    + )} +
    +
    + ) +} + +// ─── InstallHooksCard ──────────────────────────────────────────────────────── + +function InstallHooksCard({ installing, error, onInstall }: { + installing: boolean; error: string | null; onInstall: () => void +}) { + return ( +
    +
    + +
    +

    Connect Claude Code Sessions

    +

    + Install lightweight hooks that capture Claude Code session activity into the launcher. + Hooks run asynchronously and never block Claude. +

    + {error &&

    {error}

    } + +

    + Adds entries to ~/.claude/settings.json · Writes ~/.claude/hooks/ushadow_launcher_hook.py +

    +
    + ) +} + +// ─── ClaudeSessionsPanel ──────────────────────────────────────────────────── + +export function ClaudeSessionsPanel({ + sessions, hooksInstalled, installing, error, installSuccess, onInstallHooks, onOpenInApp, +}: ClaudeSessionsPanelProps) { + const [selectedId, setSelectedId] = useState(null) + const [sortInverted, setSortInverted] = useState(false) + const [allTickets, setAllTickets] = useState([]) + + // Fetch kanban tickets once on mount + useEffect(() => { + tauri.getTickets().then(setAllTickets).catch(() => {}) + }, []) + + // Apply sort inversion before splitting into active/ended + const allSessions = sortInverted ? [...sessions].reverse() : sessions + const active = allSessions.filter(s => s.status !== 'Ended') + const ended = allSessions.filter(s => s.status === 'Ended').slice(0, 5) + const selectedSession = + allSessions.find(s => s.session_id === selectedId) ?? + active[0] ?? + ended[0] ?? + null + + // Update selectedId when the auto-selected session changes (new session appears) + useEffect(() => { + if (!selectedId && active.length > 0) { + setSelectedId(active[0].session_id) + } + }, [active, selectedId]) + + // Tickets for the selected session: match by environment name or worktree path + const sessionTickets = selectedSession + ? allTickets.filter(t => + (t.environment_name && t.environment_name === selectedSession.environment?.name) || + (t.worktree_path && selectedSession.cwd.startsWith(t.worktree_path)) + ) + : [] + + return ( +
    + {/* Header — full width */} +
    +
    + +

    Claude Sessions

    + {active.length > 0 && ( + + {active.length} active + + )} +
    + {hooksInstalled && ( +
    + {installSuccess && !installing && ( + + + Updated + + )} + {error && !installing && ( + + {error} + + )} +
    +
    + Hooks active +
    + +
    + )} +
    + + {/* Loading / not-installed states — span both columns */} + {hooksInstalled === null && ( +
    + + Checking hooks… +
    + )} + {hooksInstalled === false && ( + + )} + + {/* Two-column body */} + {hooksInstalled && ( +
    + {/* ── Left: session tiles ── */} +
    +
    + Sessions + +
    + {active.length === 0 && ended.length === 0 && ( +
    + +

    No sessions yet.

    +

    + Run claude in any worktree. +

    +
    + )} + + {active.length > 0 && ( +
    +

    Active

    +
    + {active.map(s => ( + setSelectedId(s.session_id)} + /> + ))} +
    +
    + )} + + {ended.length > 0 && ( +
    +

    Ended

    +
    + {ended.map(s => ( + setSelectedId(s.session_id)} + /> + ))} +
    +
    + )} + +
    + + Last 24 hours +
    +
    + + {/* ── Right: session detail ── */} +
    + {selectedSession ? ( + + ) : ( +
    + +

    Select a session

    +
    + )} +
    +
    + )} +
    + ) +} diff --git a/ushadow/launcher/src/components/CreateEpicDialog.tsx b/ushadow/launcher/src/components/CreateEpicDialog.tsx new file mode 100644 index 00000000..d43c7a89 --- /dev/null +++ b/ushadow/launcher/src/components/CreateEpicDialog.tsx @@ -0,0 +1,180 @@ +import { useState } from 'react' + +interface CreateEpicDialogProps { + isOpen: boolean + onClose: () => void + onCreated: () => void + projectId?: string + backendUrl: string +} + +const PRESET_COLORS = [ + '#3B82F6', // blue + '#8B5CF6', // purple + '#EC4899', // pink + '#F59E0B', // amber + '#10B981', // green + '#06B6D4', // cyan + '#F97316', // orange + '#EF4444', // red +] + +export function CreateEpicDialog({ + isOpen, + onClose, + onCreated, + projectId, + backendUrl, +}: CreateEpicDialogProps) { + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [color, setColor] = useState(PRESET_COLORS[0]) + const [baseBranch, setBaseBranch] = useState('main') + const [creating, setCreating] = useState(false) + const [error, setError] = useState(null) + + if (!isOpen) return null + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + setCreating(true) + + try { + const { tauri } = await import('../hooks/useTauri') + + await tauri.createEpic( + title, + description || null, + color, + baseBranch, + null, // branch_name (not set during creation) + projectId || null + ) + + onCreated() + // Reset form + setTitle('') + setDescription('') + setColor(PRESET_COLORS[0]) + setBaseBranch('main') + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create epic') + } finally { + setCreating(false) + } + } + + return ( +
    +
    e.stopPropagation()} + > +

    Create New Epic

    + +
    + {/* Title */} +
    + + setTitle(e.target.value)} + className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-white" + placeholder="Epic title" + required + data-testid="create-epic-title" + /> +
    + + {/* Description */} +
    + +