Files
Termix/src/backend/database/routes/open-tabs.ts
T

375 lines
10 KiB
TypeScript
Raw Normal View History

2026-05-28 22:05:25 -04:00
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { userOpenTabs } from "../db/schema.js";
import { eq, and, sql } from "drizzle-orm";
import type { Request, Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { sessionManager } from "../../ssh/terminal-session-manager.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* @openapi
* /open-tabs:
* get:
* summary: Get all open tabs for the current user
* tags:
* - Open Tabs
* responses:
* 200:
* description: List of open tabs ordered by tab_order.
*/
const TAB_TTL_MS = 30 * 60 * 1000;
+2
2026-06-16 15:59:53 -05:00
// Legacy tab types that were renamed. Normalize on read so previously saved
// tabs still restore to the correct (renamed) tab type.
const LEGACY_TAB_TYPE_MAP: Record<string, string> = {
stats: "host-metrics",
};
function normalizeTabType(tabType: string): string {
return LEGACY_TAB_TYPE_MAP[tabType] ?? tabType;
}
2026-05-28 22:05:25 -04:00
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const cutoff = new Date(Date.now() - TAB_TTL_MS).toISOString();
const tabs = db
.select()
.from(userOpenTabs)
2026-06-04 15:16:53 -04:00
.where(
and(
eq(userOpenTabs.userId, userId),
sql`${userOpenTabs.updatedAt} > ${cutoff}`,
),
)
2026-05-28 22:05:25 -04:00
.orderBy(userOpenTabs.tabOrder)
.all();
+2
2026-06-16 15:59:53 -05:00
return res.json(
tabs.map((tab) => ({ ...tab, tabType: normalizeTabType(tab.tabType) })),
);
2026-05-28 22:05:25 -04:00
} catch (e) {
2026-06-04 15:16:53 -04:00
databaseLogger.error("Failed to get open tabs", e, {
operation: "get_open_tabs",
userId,
});
2026-05-28 22:05:25 -04:00
return res.status(500).json({ error: "Failed to get open tabs" });
}
});
/**
* @openapi
* /open-tabs:
* post:
* summary: Upsert a single open tab for the current user
* tags:
* - Open Tabs
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [id, tabType, label, tabOrder]
* properties:
* id:
* type: string
* tabType:
* type: string
* hostId:
* type: integer
* nullable: true
* label:
* type: string
* tabOrder:
* type: integer
* backendSessionId:
* type: string
* nullable: true
* responses:
* 200:
* description: Tab upserted successfully.
*/
router.post("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
2026-06-04 15:16:53 -04:00
const { id, tabType, hostId, label, tabOrder, backendSessionId } =
req.body as {
id: string;
tabType: string;
hostId?: number | null;
label: string;
tabOrder: number;
backendSessionId?: string | null;
};
2026-05-28 22:05:25 -04:00
if (!id || !tabType || !label) {
2026-06-04 15:16:53 -04:00
return res
.status(400)
.json({ error: "id, tabType, and label are required" });
2026-05-28 22:05:25 -04:00
}
try {
const now = new Date().toISOString();
2026-06-04 15:16:53 -04:00
const existing = db
.select()
.from(userOpenTabs)
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
.all();
2026-05-28 22:05:25 -04:00
if (existing.length > 0) {
// Preserve existing backendSessionId when not explicitly provided
2026-06-04 15:16:53 -04:00
const sessionId =
backendSessionId !== undefined
? backendSessionId
: existing[0].backendSessionId;
2026-05-28 22:05:25 -04:00
db.update(userOpenTabs)
2026-06-04 15:16:53 -04:00
.set({
tabType,
hostId: hostId ?? null,
label,
tabOrder,
backendSessionId: sessionId ?? null,
updatedAt: now,
})
2026-05-28 22:05:25 -04:00
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
.run();
} else {
2026-06-04 15:16:53 -04:00
db.insert(userOpenTabs)
.values({
id,
userId,
tabType,
hostId: hostId ?? null,
label,
tabOrder,
backendSessionId: backendSessionId ?? null,
updatedAt: now,
})
.run();
2026-05-28 22:05:25 -04:00
}
return res.json({ success: true });
} catch (e) {
2026-06-04 15:16:53 -04:00
databaseLogger.error("Failed to upsert open tab", e, {
operation: "upsert_open_tab",
userId,
id,
});
2026-05-28 22:05:25 -04:00
return res.status(500).json({ error: "Failed to upsert open tab" });
}
});
/**
* @openapi
* /open-tabs:
* put:
* summary: Bulk replace all open tabs for the current user
* tags:
* - Open Tabs
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* tabs:
* type: array
* responses:
* 200:
* description: Tabs updated successfully.
*/
router.put("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { tabs } = req.body as {
tabs: Array<{
id: string;
tabType: string;
hostId?: number | null;
label: string;
tabOrder: number;
backendSessionId?: string | null;
}>;
};
if (!Array.isArray(tabs)) {
return res.status(400).json({ error: "tabs must be an array" });
}
try {
db.delete(userOpenTabs).where(eq(userOpenTabs.userId, userId)).run();
if (tabs.length > 0) {
const now = new Date().toISOString();
2026-06-04 15:16:53 -04:00
db.insert(userOpenTabs)
.values(
tabs.map((t) => ({
id: t.id,
userId,
tabType: t.tabType,
hostId: t.hostId ?? null,
label: t.label,
tabOrder: t.tabOrder,
backendSessionId: t.backendSessionId ?? null,
updatedAt: now,
})),
)
.run();
2026-05-28 22:05:25 -04:00
}
return res.json({ success: true });
} catch (e) {
2026-06-04 15:16:53 -04:00
databaseLogger.error("Failed to sync open tabs", e, {
operation: "sync_open_tabs",
userId,
});
2026-05-28 22:05:25 -04:00
return res.status(500).json({ error: "Failed to sync open tabs" });
}
});
/**
* @openapi
* /open-tabs/{id}:
* patch:
* summary: Update a single open tab
* tags:
* - Open Tabs
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Tab updated successfully.
* 404:
* description: Tab not found.
*/
router.patch("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = String(req.params.id);
const updates = req.body as Partial<{
label: string;
tabOrder: number;
backendSessionId: string | null;
}>;
try {
const result = db
.update(userOpenTabs)
.set({ ...updates, updatedAt: new Date().toISOString() })
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
.run();
if (result.changes === 0) {
return res.status(404).json({ error: "Tab not found" });
}
return res.json({ success: true });
} catch (e) {
2026-06-04 15:16:53 -04:00
databaseLogger.error("Failed to update open tab", e, {
operation: "update_open_tab",
userId,
id,
});
2026-05-28 22:05:25 -04:00
return res.status(500).json({ error: "Failed to update open tab" });
}
});
/**
* @openapi
* /open-tabs/{id}:
* delete:
* summary: Delete a single open tab
* tags:
* - Open Tabs
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Tab deleted successfully.
*/
router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = String(req.params.id);
try {
db.delete(userOpenTabs)
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
.run();
return res.json({ success: true });
} catch (e) {
2026-06-04 15:16:53 -04:00
databaseLogger.error("Failed to delete open tab", e, {
operation: "delete_open_tab",
userId,
id,
});
2026-05-28 22:05:25 -04:00
return res.status(500).json({ error: "Failed to delete open tab" });
}
});
/**
* @openapi
* /open-tabs/active-sessions:
* get:
* summary: Get all active backend sessions for the current user
* description: Returns live terminal sessions from the session manager. Used by the Active Connections panel and tab restore logic.
* tags:
* - Open Tabs
* responses:
* 200:
* description: List of active sessions.
* content:
* application/json:
* schema:
* type: array
* items:
* type: object
* properties:
* sessionId:
* type: string
* hostId:
* type: integer
* hostName:
* type: string
* tabInstanceId:
* type: string
* isConnected:
* type: boolean
* createdAt:
* type: number
*/
2026-06-04 15:16:53 -04:00
router.get(
"/active-sessions",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const sessions = sessionManager.getUserSessions(userId);
return res.json(
sessions.map((s) => ({
sessionId: s.id,
hostId: s.hostId,
hostName: s.hostName,
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
isConnected: s.isConnected,
createdAt: s.createdAt,
})),
);
} catch (e) {
databaseLogger.error("Failed to get active sessions", e, {
operation: "get_active_sessions",
userId,
});
return res.status(500).json({ error: "Failed to get active sessions" });
}
},
);
2026-05-28 22:05:25 -04:00
export default router;