Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2452d4402 | |||
| c590431c1f | |||
| 80a5f3bf42 | |||
| 8ca6b7e166 | |||
| eb781a87ce | |||
| b933b4c2e2 | |||
| e101577d61 | |||
| 7a13d21caa | |||
| cdadc89cb0 | |||
| 296819df63 |
@@ -2,6 +2,23 @@
|
||||
|
||||
Todas as alterações notáveis neste projecto serão documentadas neste ficheiro.
|
||||
|
||||
## [2.7.0] - 2026-04-23
|
||||
|
||||
### Added — Observabilidade (Espelho)
|
||||
- Painel `/sessions` para replay de sessões Claude Code (lista + timeline detalhe)
|
||||
- Indexer `api/scripts/sessions-indexer.ts` (modos `--full` e `--watch`)
|
||||
- SQLite local em `~/.claude-work/sessions.db` (1608 sessões, 61 projectos)
|
||||
- Rotas `GET /api/sessions` e `GET /api/sessions/:id` com validação Zod
|
||||
- Watcher chokidar incremental + systemd user service `observabilidade-indexer.service`
|
||||
- UI React com filtros (período/projecto/tool/skill/search) e timeline colapsável
|
||||
|
||||
### Technical Notes
|
||||
- `better-sqlite3` (WAL + synchronous=NORMAL) + `chokidar`
|
||||
- Batching transaccional 50 rows/commit no indexer (full scan: 1603 ficheiros em 8s)
|
||||
- Proxy Vite `/api` → `localhost:3001`
|
||||
- Hub: `/media/ealmeida/Dados/Hub/05-Projectos/Observabilidade/`
|
||||
- Desk task #2059, project #65
|
||||
|
||||
## [2.6.0] - 2026-02-14
|
||||
|
||||
### Security - Vulnerabilidades Críticas (3)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Rota /api/sessions — lista e detalhe de sessões Claude Code.
|
||||
* Validação Zod em query params; detalhe carrega eventos via parseSessionFile.
|
||||
* @author Descomplicar® | Projecto Observabilidade (Espelho)
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import type { SessionsDb } from '../services/sessions/db.js'
|
||||
import { parseSessionFile } from '../services/sessions/parser.js'
|
||||
|
||||
const ListQuerySchema = z.object({
|
||||
days: z.coerce.number().int().min(1).max(3650).optional(),
|
||||
project: z.string().min(1).max(200).optional(),
|
||||
tool: z.string().min(1).max(100).optional(),
|
||||
skill: z.string().min(1).max(200).optional(),
|
||||
q: z.string().max(500).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
})
|
||||
|
||||
const IdParamSchema = z.object({ id: z.string().min(1).max(200) })
|
||||
|
||||
export function createSessionsRouter(db: SessionsDb): Router {
|
||||
const router = Router()
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const parsed = ListQuerySchema.safeParse(req.query)
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid query', details: parsed.error.format() })
|
||||
}
|
||||
const filters = parsed.data
|
||||
const items = db.listSessions(filters)
|
||||
const total = db.countSessions(filters)
|
||||
return res.json({ total, items })
|
||||
})
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
const parsed = IdParamSchema.safeParse(req.params)
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid id' })
|
||||
}
|
||||
const session = db.getSession(parsed.data.id)
|
||||
if (!session) return res.status(404).json({ error: 'Session not found' })
|
||||
|
||||
try {
|
||||
const { events } = await parseSessionFile(session.jsonl_path)
|
||||
return res.json({ meta: session, events })
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException
|
||||
if (e.code === 'ENOENT') {
|
||||
return res.status(410).json({
|
||||
error: 'Session file missing (stale index)',
|
||||
session_id: parsed.data.id,
|
||||
})
|
||||
}
|
||||
const isProduction = process.env.NODE_ENV === 'production'
|
||||
return res.status(500).json({
|
||||
error: 'Failed to parse session',
|
||||
...(isProduction ? {} : { message: e.message }),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* CLI do indexer de sessões Claude Code (Observabilidade/Espelho).
|
||||
*
|
||||
* Modos:
|
||||
* --full Full scan de ~/.claude/projects -> SQLite em ~/.claude-work/sessions.db
|
||||
* --watch Modo incremental (stub; implementação Task 8)
|
||||
*
|
||||
* Env:
|
||||
* OBSERVABILIDADE_DB Override ao caminho da BD SQLite
|
||||
*/
|
||||
import { indexAll, DEFAULT_DB_PATH, PROJECTS_ROOT } from '../services/sessions/indexer.js'
|
||||
import { startWatcher } from '../services/sessions/watcher.js'
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2)
|
||||
const mode = args.find((a) => a === '--full' || a === '--watch')
|
||||
if (!mode) {
|
||||
console.error('Uso: sessions-indexer.ts [--full|--watch]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const dbPath = process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH
|
||||
console.log(`[indexer] modo=${mode} db=${dbPath}`)
|
||||
|
||||
if (mode === '--watch') {
|
||||
console.log(`[indexer] watch mode em ${PROJECTS_ROOT} -> ${dbPath}`)
|
||||
await indexAll({ dbPath })
|
||||
await startWatcher(dbPath)
|
||||
return
|
||||
}
|
||||
|
||||
const start = Date.now()
|
||||
let lastLogged = 0
|
||||
const { indexed, failed } = await indexAll({
|
||||
dbPath,
|
||||
onProgress: (done, total) => {
|
||||
if (done - lastLogged >= 50 || done === total) {
|
||||
console.log(`[indexer] ${done}/${total}`)
|
||||
lastLogged = done
|
||||
}
|
||||
},
|
||||
})
|
||||
const durationMs = Date.now() - start
|
||||
const durationSec = (durationMs / 1000).toFixed(1)
|
||||
console.log(`[indexer] concluído em ${durationSec}s · indexed=${indexed} failed=${failed}`)
|
||||
process.exit(failed > 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[indexer] falha fatal:', err)
|
||||
process.exit(2)
|
||||
})
|
||||
@@ -21,6 +21,9 @@ import n8nRouter from './routes/n8n.js'
|
||||
import paperclipRouter from './routes/paperclip.js'
|
||||
import aiRouter from './routes/ai.js'
|
||||
import operationsRouter from './routes/operations.js'
|
||||
import { createSessionsRouter } from './routes/sessions.js'
|
||||
import { openSessionsDb } from './services/sessions/db.js'
|
||||
import { DEFAULT_DB_PATH } from './services/sessions/indexer.js'
|
||||
import { collectAllServerMetrics } from './services/server-metrics.js'
|
||||
import { collectMonitoringData } from './services/monitoring-collector.js'
|
||||
|
||||
@@ -133,6 +136,20 @@ app.use('/api/paperclip', paperclipRouter)
|
||||
app.use('/api/ai', aiRouter)
|
||||
app.use('/api/operations', operationsRouter)
|
||||
|
||||
// Observabilidade (Espelho) — sessões Claude Code
|
||||
const sessionsDb = openSessionsDb(process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH)
|
||||
app.use('/api/sessions', createSessionsRouter(sessionsDb))
|
||||
|
||||
function closeSessionsDb(): void {
|
||||
try {
|
||||
sessionsDb.close()
|
||||
} catch (err) {
|
||||
console.error('[sessionsDb] erro ao fechar:', err)
|
||||
}
|
||||
}
|
||||
process.on('SIGTERM', closeSessionsDb)
|
||||
process.on('SIGINT', closeSessionsDb)
|
||||
|
||||
// Serve static files in production
|
||||
if (isProduction) {
|
||||
// __dirname is /app/api/dist, need to go up 2 levels to /app/dist
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { readdirSync, statSync } from 'fs'
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { parseSessionFile } from './parser.js'
|
||||
import { openSessionsDb, type SessionsDb } from './db.js'
|
||||
import type { SessionMeta } from '../../types/session.js'
|
||||
|
||||
export const PROJECTS_ROOT = join(homedir(), '.claude', 'projects')
|
||||
export const DEFAULT_DB_PATH = join(homedir(), '.claude-work', 'sessions.db')
|
||||
|
||||
/**
|
||||
* Percorre a raiz de projectos Claude (profundidade 2) e devolve todos os .jsonl.
|
||||
* Estrutura: ~/.claude/projects/<project-slug>/<session-uuid>.jsonl
|
||||
*/
|
||||
export function findAllJsonl(root: string = PROJECTS_ROOT): string[] {
|
||||
const result: string[] = []
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(root)
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const projectDir = join(root, entry)
|
||||
let st
|
||||
try {
|
||||
st = statSync(projectDir)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!st.isDirectory()) continue
|
||||
let files: string[]
|
||||
try {
|
||||
files = readdirSync(projectDir)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const f of files) {
|
||||
if (f.endsWith('.jsonl')) result.push(join(projectDir, f))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexa um único ficheiro (parse + upsert). Uso individual — útil para o watcher (Task 8).
|
||||
*/
|
||||
export async function indexFile(db: SessionsDb, path: string): Promise<void> {
|
||||
const { meta } = await parseSessionFile(path)
|
||||
db.upsertSession(meta)
|
||||
}
|
||||
|
||||
export interface IndexAllOptions {
|
||||
dbPath?: string
|
||||
onProgress?: (done: number, total: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Full scan: percorre todos os JSONL e faz upsert em lote (batch 50 via transacção).
|
||||
*/
|
||||
export async function indexAll(
|
||||
options: IndexAllOptions = {},
|
||||
): Promise<{ indexed: number; failed: number }> {
|
||||
const db = openSessionsDb(options.dbPath ?? DEFAULT_DB_PATH)
|
||||
const files = findAllJsonl()
|
||||
const BATCH = 50
|
||||
let indexed = 0
|
||||
let failed = 0
|
||||
let batch: SessionMeta[] = []
|
||||
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
try {
|
||||
const { meta } = await parseSessionFile(files[i])
|
||||
batch.push(meta)
|
||||
if (batch.length >= BATCH) {
|
||||
db.upsertMany(batch)
|
||||
indexed += batch.length
|
||||
batch = []
|
||||
}
|
||||
} catch (err) {
|
||||
failed++
|
||||
console.error(`[indexer] erro em ${files[i]}:`, err)
|
||||
}
|
||||
if (options.onProgress) {
|
||||
options.onProgress(indexed + failed + batch.length, files.length)
|
||||
}
|
||||
}
|
||||
if (batch.length > 0) {
|
||||
db.upsertMany(batch)
|
||||
indexed += batch.length
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
|
||||
return { indexed, failed }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import chokidar from 'chokidar'
|
||||
import { openSessionsDb } from './db.js'
|
||||
import { indexFile, PROJECTS_ROOT } from './indexer.js'
|
||||
|
||||
export async function startWatcher(dbPath: string): Promise<void> {
|
||||
const db = openSessionsDb(dbPath)
|
||||
const watcher = chokidar.watch(`${PROJECTS_ROOT}/**/*.jsonl`, {
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: { stabilityThreshold: 2000, pollInterval: 500 },
|
||||
})
|
||||
|
||||
async function reindex(path: string): Promise<void> {
|
||||
try {
|
||||
await indexFile(db, path)
|
||||
console.log(`[watcher] indexed ${path}`)
|
||||
} catch (err) {
|
||||
console.error(`[watcher] erro ${path}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
watcher
|
||||
.on('add', reindex)
|
||||
.on('change', reindex)
|
||||
.on('unlink', (path) => {
|
||||
db.deleteByJsonlPath(path)
|
||||
console.log(`[watcher] removed ${path}`)
|
||||
})
|
||||
.on('error', (err) => console.error('[watcher] error:', err))
|
||||
|
||||
console.log('[watcher] pronto')
|
||||
|
||||
// Registar handler SIGTERM/SIGINT para fechar DB limpa (evita WAL corruption em Task 9 systemd restart)
|
||||
const cleanup = async (): Promise<void> => {
|
||||
console.log('[watcher] SIGTERM/SIGINT — a fechar watcher e DB')
|
||||
await watcher.close()
|
||||
db.close()
|
||||
process.exit(0)
|
||||
}
|
||||
process.on('SIGTERM', () => { void cleanup() })
|
||||
process.on('SIGINT', () => { void cleanup() })
|
||||
|
||||
return new Promise(() => {}) // nunca resolve — processo mantém-se vivo
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Testes da rota /api/sessions (validação Zod + integração com SessionsDb).
|
||||
* @author Descomplicar® | Projecto Observabilidade (Espelho)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import express from 'express'
|
||||
import request from 'supertest'
|
||||
import { mkdtempSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { openSessionsDb } from '../services/sessions/db.js'
|
||||
import { createSessionsRouter } from '../routes/sessions.js'
|
||||
import type { SessionMeta } from '../types/session.js'
|
||||
|
||||
function meta(overrides: Partial<SessionMeta> = {}): SessionMeta {
|
||||
return {
|
||||
session_id: 's1',
|
||||
project_path: '/tmp/p',
|
||||
project_slug: 'p',
|
||||
jsonl_path: '/tmp/p/s1.jsonl',
|
||||
started_at: new Date().toISOString(),
|
||||
ended_at: null,
|
||||
duration_sec: 60,
|
||||
event_count: 10,
|
||||
user_messages: 2,
|
||||
assistant_msgs: 5,
|
||||
tool_calls: 3,
|
||||
first_prompt: 'teste',
|
||||
tools_used: ['Bash'],
|
||||
skills_invoked: [],
|
||||
outcome: 'completed',
|
||||
permission_mode: 'default',
|
||||
file_size: 1000,
|
||||
indexed_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('GET /api/sessions', () => {
|
||||
let app: express.Express
|
||||
beforeAll(() => {
|
||||
const dbPath = join(mkdtempSync(join(tmpdir(), 'obs-r-')), 'sessions.db')
|
||||
const db = openSessionsDb(dbPath)
|
||||
db.upsertSession(meta({ session_id: 's1', project_slug: 'alpha', jsonl_path: '/tmp/p/s1.jsonl' }))
|
||||
db.upsertSession(meta({ session_id: 's2', project_slug: 'beta', jsonl_path: '/tmp/p/s2.jsonl' }))
|
||||
app = express()
|
||||
app.use('/api/sessions', createSessionsRouter(db))
|
||||
})
|
||||
|
||||
it('lista todas as sessões por omissão', async () => {
|
||||
const res = await request(app).get('/api/sessions')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.total).toBe(2)
|
||||
expect(res.body.items).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('filtra por projecto', async () => {
|
||||
const res = await request(app).get('/api/sessions').query({ project: 'alpha' })
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.total).toBe(1)
|
||||
expect(res.body.items[0].project_slug).toBe('alpha')
|
||||
})
|
||||
|
||||
it('rejeita limit inválido', async () => {
|
||||
const res = await request(app).get('/api/sessions').query({ limit: '9999' })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
+239
-28
@@ -45,6 +45,7 @@
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
"autoprefixer": "^10.4.24",
|
||||
@@ -55,6 +56,7 @@
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^28.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"supertest": "^7.2.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "~5.9.3",
|
||||
@@ -176,7 +178,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -526,7 +527,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
@@ -567,7 +567,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
@@ -1306,6 +1305,19 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@panva/asn1.js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz",
|
||||
@@ -1315,6 +1327,16 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -2138,7 +2160,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -2239,6 +2262,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookiejar": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||
"integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
@@ -2380,6 +2410,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
"integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.10.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.10.tgz",
|
||||
@@ -2420,7 +2457,6 @@
|
||||
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2431,7 +2467,6 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -2493,6 +2528,30 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/superagent": {
|
||||
"version": "8.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz",
|
||||
"integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cookiejar": "^2.1.5",
|
||||
"@types/methods": "^1.1.4",
|
||||
"@types/node": "*",
|
||||
"form-data": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/supertest": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz",
|
||||
"integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/methods": "^1.1.4",
|
||||
"@types/superagent": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
@@ -2544,7 +2603,6 @@
|
||||
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.54.0",
|
||||
"@typescript-eslint/types": "8.54.0",
|
||||
@@ -2893,7 +2951,6 @@
|
||||
"integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.0.18",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -2943,7 +3000,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3108,6 +3164,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/asap": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||
@@ -3136,6 +3199,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.24",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
|
||||
@@ -3375,7 +3445,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -3665,6 +3734,29 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/component-emitter": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
|
||||
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -3760,6 +3852,13 @@
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookiejar": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
|
||||
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
@@ -4209,6 +4308,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
@@ -4256,12 +4365,24 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
|
||||
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"asap": "^2.0.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
@@ -4593,7 +4714,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -4821,7 +4941,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -5001,6 +5120,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-safe-stringify": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -5154,6 +5280,23 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
@@ -5166,6 +5309,24 @@
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/formidable": {
|
||||
"version": "3.5.4",
|
||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
|
||||
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"dezalgo": "^1.0.4",
|
||||
"once": "^1.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/tunnckoCore/commissions"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -6877,6 +7038,7 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -7324,7 +7486,6 @@
|
||||
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz",
|
||||
"integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"jwt-decode": "^4.0.0"
|
||||
},
|
||||
@@ -7559,7 +7720,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
@@ -7657,7 +7817,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -7694,7 +7853,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -7793,6 +7951,7 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -7808,6 +7967,7 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -7820,7 +7980,8 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
@@ -7935,7 +8096,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -7945,7 +8105,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -7978,7 +8137,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
@@ -8120,8 +8278,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -8892,6 +9049,65 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent": {
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
|
||||
"integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"component-emitter": "^1.3.1",
|
||||
"cookiejar": "^2.1.4",
|
||||
"debug": "^4.3.7",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"form-data": "^4.0.5",
|
||||
"formidable": "^3.5.4",
|
||||
"methods": "^1.1.2",
|
||||
"mime": "2.6.0",
|
||||
"qs": "^6.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent/node_modules/mime": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supertest": {
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
|
||||
"integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie-signature": "^1.2.2",
|
||||
"methods": "^1.1.2",
|
||||
"superagent": "^10.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supertest/node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@@ -9108,7 +9324,6 @@
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -9247,7 +9462,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -9446,7 +9660,6 @@
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -9522,7 +9735,6 @@
|
||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.18",
|
||||
"@vitest/mocker": "4.0.18",
|
||||
@@ -9869,7 +10081,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
Executable → Regular
+2
@@ -53,6 +53,7 @@
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
"autoprefixer": "^10.4.24",
|
||||
@@ -63,6 +64,7 @@
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^28.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"supertest": "^7.2.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "~5.9.3",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Bot,
|
||||
Brain,
|
||||
ClipboardList,
|
||||
Eye,
|
||||
Zap,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -35,6 +36,7 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/paperclip', label: 'Paperclip', icon: Bot },
|
||||
{ to: '/ai', label: 'IA / Claude', icon: Brain },
|
||||
{ to: '/operations', label: 'Operações', icon: ClipboardList },
|
||||
{ to: '/sessions', label: 'Espelho', icon: Eye },
|
||||
]
|
||||
|
||||
function useIsMobile() {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
import type { SessionEvent } from '../../../api/types/session'
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
return s.length > n ? s.slice(0, n) + '…' : s
|
||||
}
|
||||
|
||||
interface Props {
|
||||
event: SessionEvent
|
||||
defaultCollapsed: boolean
|
||||
}
|
||||
|
||||
export function EventBlock({ event, defaultCollapsed }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed)
|
||||
|
||||
const base = 'rounded border px-3 py-2 my-1 text-sm'
|
||||
switch (event.type) {
|
||||
case 'user':
|
||||
if (event.tool_result !== null && event.tool_result !== undefined) {
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-slate-900/40 border-slate-700`}>
|
||||
<button onClick={() => setCollapsed(!collapsed)} className="text-xs text-slate-500 uppercase">
|
||||
tool_result {collapsed ? '▸' : '▾'}
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<pre className="mt-2 text-xs overflow-x-auto whitespace-pre-wrap text-slate-300">
|
||||
{typeof event.tool_result === 'string' ? event.tool_result : JSON.stringify(event.tool_result, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-blue-500/10 border-blue-500/30`}>
|
||||
<div className="text-xs text-blue-300 uppercase mb-1">user</div>
|
||||
<div className="whitespace-pre-wrap text-slate-100">{event.text ?? '—'}</div>
|
||||
</div>
|
||||
)
|
||||
case 'assistant':
|
||||
if (event.tool_name) {
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-amber-500/10 border-amber-500/30`}>
|
||||
<button onClick={() => setCollapsed(!collapsed)} className="text-xs text-amber-300 uppercase">
|
||||
tool_use: {event.tool_name} {collapsed ? '▸' : '▾'}
|
||||
</button>
|
||||
{!collapsed && event.tool_input && (
|
||||
<pre className="mt-2 text-xs overflow-x-auto whitespace-pre-wrap text-slate-300">
|
||||
{JSON.stringify(event.tool_input, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-slate-800/40 border-slate-700`}>
|
||||
<div className="text-xs text-slate-500 uppercase mb-1">assistant</div>
|
||||
<div className="whitespace-pre-wrap text-slate-200">{truncate(event.text ?? '—', collapsed ? 300 : Number.MAX_SAFE_INTEGER)}</div>
|
||||
{(event.text?.length ?? 0) > 300 && (
|
||||
<button onClick={() => setCollapsed(!collapsed)} className="text-xs text-slate-500 mt-1">
|
||||
{collapsed ? 'Expandir' : 'Colapsar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
case 'system':
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-slate-900/30 border-slate-800 text-xs text-slate-500`}>
|
||||
<button onClick={() => setCollapsed(!collapsed)} className="uppercase">
|
||||
system {event.skill_invoked ? `· skill: ${event.skill_invoked}` : ''} {event.hook_name ? `· hook: ${event.hook_name}` : ''} {collapsed ? '▸' : '▾'}
|
||||
</button>
|
||||
{!collapsed && <div className="mt-2 whitespace-pre-wrap">{event.text ?? JSON.stringify(event.raw)}</div>}
|
||||
</div>
|
||||
)
|
||||
case 'attachment':
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-purple-500/10 border-purple-500/30 text-xs text-purple-300`}>
|
||||
📎 attachment
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-slate-900/20 border-slate-800 text-xs text-slate-500`}>
|
||||
{event.type}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export interface Filters {
|
||||
days: number
|
||||
project: string
|
||||
tool: string
|
||||
skill: string
|
||||
q: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initial: Filters
|
||||
projects: string[]
|
||||
tools: string[]
|
||||
skills: string[]
|
||||
onChange: (f: Filters) => void
|
||||
}
|
||||
|
||||
export function FilterBar({ initial, projects, tools, skills, onChange }: Props) {
|
||||
const [f, setF] = useState<Filters>(initial)
|
||||
|
||||
function update(partial: Partial<Filters>) {
|
||||
const next = { ...f, ...partial }
|
||||
setF(next)
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3 p-4 bg-white/5 rounded-lg backdrop-blur border border-white/10">
|
||||
<select
|
||||
value={f.days}
|
||||
onChange={(e) => update({ days: Number(e.target.value) })}
|
||||
className="bg-slate-900 border border-white/10 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
<option value={1}>24h</option>
|
||||
<option value={7}>7 dias</option>
|
||||
<option value={30}>30 dias</option>
|
||||
<option value={90}>90 dias</option>
|
||||
<option value={3650}>Tudo</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={f.project}
|
||||
onChange={(e) => update({ project: e.target.value })}
|
||||
className="bg-slate-900 border border-white/10 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Todos os projectos</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={f.tool}
|
||||
onChange={(e) => update({ tool: e.target.value })}
|
||||
className="bg-slate-900 border border-white/10 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Qualquer tool</option>
|
||||
{tools.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={f.skill}
|
||||
onChange={(e) => update({ skill: e.target.value })}
|
||||
className="bg-slate-900 border border-white/10 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Qualquer skill</option>
|
||||
{skills.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Pesquisar no prompt inicial…"
|
||||
value={f.q}
|
||||
onChange={(e) => update({ q: e.target.value })}
|
||||
className="flex-1 min-w-[200px] bg-slate-900 border border-white/10 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { SessionMeta } from '../../../api/types/session'
|
||||
|
||||
function formatDuration(sec: number | null): string {
|
||||
if (!sec) return '—'
|
||||
if (sec < 60) return `${sec}s`
|
||||
if (sec < 3600) return `${Math.round(sec / 60)}min`
|
||||
return `${Math.floor(sec / 3600)}h${Math.round((sec % 3600) / 60)}m`
|
||||
}
|
||||
|
||||
function outcomeIcon(o: SessionMeta['outcome']): string {
|
||||
switch (o) {
|
||||
case 'completed':
|
||||
return '✓'
|
||||
case 'error':
|
||||
return '✗'
|
||||
case 'interrupted':
|
||||
return '⚠'
|
||||
default:
|
||||
return '?'
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
session: SessionMeta
|
||||
}
|
||||
|
||||
export function SessionRow({ session }: Props) {
|
||||
const when = new Date(session.started_at).toLocaleString('pt-PT', { dateStyle: 'short', timeStyle: 'short' })
|
||||
return (
|
||||
<tr className="border-b border-white/5 hover:bg-white/5">
|
||||
<td className="px-3 py-2 text-sm text-slate-300">{when}</td>
|
||||
<td className="px-3 py-2 text-sm text-slate-400">{session.project_slug}</td>
|
||||
<td className="px-3 py-2 text-sm text-slate-200">
|
||||
<Link to={`/sessions/${session.session_id}`} className="hover:underline">
|
||||
{session.first_prompt?.slice(0, 80) ?? '—'}
|
||||
{(session.first_prompt?.length ?? 0) > 80 ? '…' : ''}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm text-slate-400">{formatDuration(session.duration_sec)}</td>
|
||||
<td className="px-3 py-2 text-sm text-right text-slate-400">{session.event_count}</td>
|
||||
<td className="px-3 py-2 text-sm text-right text-slate-400">{session.tool_calls}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{session.skills_invoked.slice(0, 2).map((s) => (
|
||||
<span key={s} className="px-2 py-0.5 bg-indigo-500/20 text-indigo-300 rounded">
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">{outcomeIcon(session.outcome)}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { SessionMeta, SessionEvent } from '../../../api/types/session'
|
||||
|
||||
export interface ListParams {
|
||||
days?: number
|
||||
project?: string
|
||||
tool?: string
|
||||
skill?: string
|
||||
q?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface ListResponse {
|
||||
total: number
|
||||
items: SessionMeta[]
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? ''
|
||||
|
||||
function buildQuery(params: Record<string, unknown>): string {
|
||||
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== '' && v !== null)
|
||||
if (entries.length === 0) return ''
|
||||
return '?' + new URLSearchParams(entries as [string, string][]).toString()
|
||||
}
|
||||
|
||||
export async function listSessions(params: ListParams): Promise<ListResponse> {
|
||||
const res = await fetch(`${API_BASE}/api/sessions${buildQuery(params as Record<string, unknown>)}`)
|
||||
if (!res.ok) throw new Error(`listSessions failed: ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function getSession(id: string): Promise<{ meta: SessionMeta; events: SessionEvent[] }> {
|
||||
const res = await fetch(`${API_BASE}/api/sessions/${encodeURIComponent(id)}`)
|
||||
if (!res.ok) throw new Error(`getSession failed: ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import N8nMonitor from './pages/N8nMonitor.tsx'
|
||||
import Paperclip from './pages/Paperclip.tsx'
|
||||
import AiOverview from './pages/AiOverview.tsx'
|
||||
import Operations from './pages/Operations.tsx'
|
||||
import Sessions from './pages/Sessions.tsx'
|
||||
import SessionDetail from './pages/SessionDetail.tsx'
|
||||
import Layout from './components/Layout.tsx'
|
||||
import { oidcConfig } from './auth/config.ts'
|
||||
import { AuthWrapper } from './auth/AuthWrapper.tsx'
|
||||
@@ -30,6 +32,8 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="/paperclip" element={<Paperclip />} />
|
||||
<Route path="/ai" element={<AiOverview />} />
|
||||
<Route path="/operations" element={<Operations />} />
|
||||
<Route path="/sessions" element={<Sessions />} />
|
||||
<Route path="/sessions/:id" element={<SessionDetail />} />
|
||||
<Route path="/callback" element={<App />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { getSession } from '../lib/api/sessions'
|
||||
import { EventBlock } from '../components/sessions/EventBlock'
|
||||
import type { SessionMeta, SessionEvent } from '../../api/types/session'
|
||||
|
||||
type FilterMode = 'all' | 'no-system' | 'tools-only' | 'prompts-only'
|
||||
|
||||
export default function SessionDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [meta, setMeta] = useState<SessionMeta | null>(null)
|
||||
const [events, setEvents] = useState<SessionEvent[]>([])
|
||||
const [mode, setMode] = useState<FilterMode>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
getSession(id)
|
||||
.then((r) => {
|
||||
setMeta(r.meta)
|
||||
setEvents(r.events)
|
||||
})
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
const visible = events.filter((e) => {
|
||||
if (mode === 'no-system') return e.type !== 'system'
|
||||
if (mode === 'tools-only') return e.tool_name !== null || e.tool_result !== null
|
||||
if (mode === 'prompts-only') return e.type === 'user' && e.tool_result === null
|
||||
return true
|
||||
})
|
||||
|
||||
if (loading) return <div className="p-6 text-slate-400">A carregar…</div>
|
||||
if (error) return <div className="p-6 text-red-300">{error}</div>
|
||||
if (!meta) return <div className="p-6 text-slate-400">Sessão não encontrada.</div>
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<Link to="/sessions" className="text-sm text-slate-400 hover:underline">
|
||||
← Voltar à lista
|
||||
</Link>
|
||||
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-xl font-semibold text-white">{meta.first_prompt?.slice(0, 120) ?? meta.session_id}</h1>
|
||||
<div className="text-sm text-slate-400 flex flex-wrap gap-4">
|
||||
<span>{new Date(meta.started_at).toLocaleString('pt-PT')}</span>
|
||||
<span>Projecto: {meta.project_slug}</span>
|
||||
<span>Duração: {meta.duration_sec ?? 0}s</span>
|
||||
<span>Eventos: {meta.event_count}</span>
|
||||
<span>Tool calls: {meta.tool_calls}</span>
|
||||
<span>Skills: {meta.skills_invoked.join(', ') || '—'}</span>
|
||||
<span>Resultado: {meta.outcome}</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">JSONL: {meta.jsonl_path}</div>
|
||||
</header>
|
||||
|
||||
<div className="flex gap-2 text-sm">
|
||||
{(['all', 'no-system', 'tools-only', 'prompts-only'] as FilterMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`px-3 py-1 rounded ${mode === m ? 'bg-indigo-500/30 text-indigo-200' : 'bg-white/5 text-slate-400'}`}
|
||||
>
|
||||
{m === 'all' ? 'Tudo' : m === 'no-system' ? 'Esconder system' : m === 'tools-only' ? 'Só tools' : 'Só prompts'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500">
|
||||
A mostrar {visible.length} de {events.length} eventos.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{visible.map((e) => (
|
||||
<EventBlock key={e.index} event={e} defaultCollapsed={e.type === 'system' || (e.tool_result !== null && e.tool_result !== undefined)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { listSessions, type ListResponse } from '../lib/api/sessions'
|
||||
import { SessionRow } from '../components/sessions/SessionRow'
|
||||
import { FilterBar, type Filters } from '../components/sessions/FilterBar'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export default function Sessions() {
|
||||
const [filters, setFilters] = useState<Filters>({ days: 7, project: '', tool: '', skill: '', q: '' })
|
||||
const [data, setData] = useState<ListResponse | null>(null)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
listSessions({
|
||||
days: filters.days,
|
||||
project: filters.project || undefined,
|
||||
tool: filters.tool || undefined,
|
||||
skill: filters.skill || undefined,
|
||||
q: filters.q || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
})
|
||||
.then((r) => {
|
||||
if (!cancelled) setData(r)
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (!cancelled) setError(e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [filters, offset])
|
||||
|
||||
const { projects, tools, skills } = useMemo(() => {
|
||||
const p = new Set<string>()
|
||||
const t = new Set<string>()
|
||||
const s = new Set<string>()
|
||||
data?.items.forEach((it) => {
|
||||
p.add(it.project_slug)
|
||||
it.tools_used.forEach((x) => t.add(x))
|
||||
it.skills_invoked.forEach((x) => s.add(x))
|
||||
})
|
||||
return { projects: [...p].sort(), tools: [...t].sort(), skills: [...s].sort() }
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold text-white">Espelho — Sessões Claude</h1>
|
||||
<p className="text-sm text-slate-400">Replay de sessões para observar comportamento real.</p>
|
||||
</header>
|
||||
|
||||
<FilterBar
|
||||
initial={filters}
|
||||
projects={projects}
|
||||
tools={tools}
|
||||
skills={skills}
|
||||
onChange={(f) => {
|
||||
setFilters(f)
|
||||
setOffset(0)
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && <div className="p-3 bg-red-500/10 border border-red-500/30 rounded text-red-300 text-sm">{error}</div>}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-white/10">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/5 text-xs uppercase text-slate-400">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Início</th>
|
||||
<th className="px-3 py-2 text-left">Projecto</th>
|
||||
<th className="px-3 py-2 text-left">Prompt</th>
|
||||
<th className="px-3 py-2 text-left">Duração</th>
|
||||
<th className="px-3 py-2 text-right">Eventos</th>
|
||||
<th className="px-3 py-2 text-right">Tools</th>
|
||||
<th className="px-3 py-2 text-left">Skills</th>
|
||||
<th className="px-3 py-2 text-center">OK</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-8 text-center text-slate-500">
|
||||
A carregar…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && data?.items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-8 text-center text-slate-500">
|
||||
Sem sessões para estes filtros.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{data?.items.map((s) => <SessionRow key={s.session_id} session={s} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-slate-400">
|
||||
<span>
|
||||
{data ? `${offset + 1}–${Math.min(offset + PAGE_SIZE, data.total)} de ${data.total}` : ''}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={offset === 0}
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
className="px-3 py-1 bg-white/5 rounded disabled:opacity-30"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
disabled={!data || offset + PAGE_SIZE >= data.total}
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
className="px-3 py-1 bg-white/5 rounded disabled:opacity-30"
|
||||
>
|
||||
Seguinte
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=Observabilidade (Espelho) — indexer incremental de sessões Claude
|
||||
After=default.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/media/ealmeida/Dados/Dev/DashDescomplicar
|
||||
ExecStart=/home/ealmeida/.nvm/versions/node/v22.22.2/bin/npx tsx api/scripts/sessions-indexer.ts --watch
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
KillMode=mixed
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=10s
|
||||
StandardOutput=append:/home/ealmeida/.claude-work/observabilidade-indexer.log
|
||||
StandardError=append:/home/ealmeida/.claude-work/observabilidade-indexer.log
|
||||
Environment="OBSERVABILIDADE_DB=/home/ealmeida/.claude-work/sessions.db"
|
||||
Environment="PATH=/home/ealmeida/.nvm/versions/node/v22.22.2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
Reference in New Issue
Block a user