From 64c931b668e6faea28ccfdf6aa19dea3ed453587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=8B=A4=ED=98=84?= Date: Tue, 24 Mar 2026 14:15:08 +0900 Subject: [PATCH 01/13] chore: add .worktrees to gitignore for isolated development workspaces Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 36447cbe5f..c6009e69e1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ config_db.inc.php **/._* upload_area/** .env +.worktrees/ From eddf6e193f74b89daf66a39803d4b384967f33fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=8B=A4=ED=98=84?= Date: Tue, 24 Mar 2026 14:20:01 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20Phase=201=20-=20=EC=9D=B8?= =?UTF-8?q?=ED=94=84=EB=9D=BC=20=EA=B8=B0=EB=B0=98=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?(Dockerfile=20PHP=208.2,=20Vue=203=20+=20Vite=20SPA=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Dockerfile | 11 +- gui/templates/dashio/main.tpl | 699 +--------------------------------- package.json | 20 + src/App.vue | 7 + src/main.js | 9 + src/router/index.js | 31 ++ src/views/DashboardView.vue | 6 + src/views/LoginView.vue | 31 ++ src/views/MilestoneView.vue | 5 + src/views/ProjectView.vue | 5 + src/views/TestRunView.vue | 5 + vite.config.js | 29 ++ 12 files changed, 169 insertions(+), 689 deletions(-) create mode 100644 src/App.vue create mode 100644 src/main.js create mode 100644 src/router/index.js create mode 100644 src/views/DashboardView.vue create mode 100644 src/views/LoginView.vue create mode 100644 src/views/MilestoneView.vue create mode 100644 src/views/ProjectView.vue create mode 100644 src/views/TestRunView.vue create mode 100644 vite.config.js diff --git a/Dockerfile b/Dockerfile index 7be6ef9c08..d5654aeea2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM php:7.4-apache +FROM php:8.2-apache RUN apt update && apt upgrade -y RUN apt install -y \ @@ -6,11 +6,14 @@ RUN apt install -y \ zlib1g-dev \ libpng-dev \ libjpeg-dev \ - libfreetype-dev + libfreetype-dev \ + curl RUN docker-php-ext-install mysqli && \ docker-php-ext-enable mysqli && \ docker-php-ext-configure gd --with-freetype --with-jpeg && \ docker-php-ext-install gd +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ + apt install -y nodejs RUN apt clean WORKDIR /var/www/html @@ -18,4 +21,6 @@ WORKDIR /var/www/html COPY . . COPY ./docker/php.ini-production /usr/local/etc/php/conf.d/php.ini -RUN chown -R www-data:www-data /var/www/html/gui/templates_c \ No newline at end of file +RUN npm ci && npm run build + +RUN chown -R www-data:www-data /var/www/html/gui/templates_c diff --git a/gui/templates/dashio/main.tpl b/gui/templates/dashio/main.tpl index f509eaf018..61d5d8eec9 100644 --- a/gui/templates/dashio/main.tpl +++ b/gui/templates/dashio/main.tpl @@ -1,686 +1,13 @@ - - - - - - - - - - Dashio - Bootstrap Admin Template - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - -
-
-
-
- -
-

USER VISITS

-
-
-
    -
  • 10.000
  • -
  • 8.000
  • -
  • 6.000
  • -
  • 4.000
  • -
  • 2.000
  • -
  • 0
  • -
-
-
JAN
-
85%
-
-
-
FEB
-
50%
-
-
-
MAR
-
60%
-
-
-
APR
-
45%
-
-
-
MAY
-
32%
-
-
-
JUN
-
62%
-
-
-
JUL
-
75%
-
-
- -
- -
-
-
-
SERVER LOAD
-
- - -
-
-

Usage
Increase:

-
-
-

21%

-
-
-
- -
- -
-
-
-
DROPBOX STATICS
-
- - -

April 17, 2014

-
-
-
17 GB
-
-
-
60% Used
-
-
-
- -
- -
- -
-
-
REVENUE
-
-
-
-
-

$ 17,980
Month Income

-
-
- -
- -
- -
-
- -

11º C

-

BUDAPEST

-
-
- - -
-
-
-
DIRECT MESSAGE
-
-
- -
-

- Dan Rogers - sent you a message. -

-

3 hours ago

-

Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s.

-
-
- -
- -
-
-
-
- -
- -
-
- -
- -
- -
- -
-
-
TOP USER
-
-

-

Zac Snider

-
-
-

MEMBER SINCE

-

2012

-
-
-

TOTAL SPEND

-

$ 47,60

-
-
-
-
- -
- -
- -

@THISISYOU
5 min. ago -

-

18 | 49

-
-
- -
- -
-
-
-
HOT
- -
Flat Pack Heritage
-
TOTAL SALES: 1388
- -
-
- - -
-
-
-
- -

DJ SHERMAN

-
-
-
- - - -
-
- -
- -
-
-
-
DISK SPACE
-
- - -

60% USED

-
-
- -
- -
- - -
- -
-

COMPLETED ACTIONS & PROGRESS

- - -
- -
-
-
-
- TODAY EARNINGS - $ 890,00 | 15% -
-
-
-
-
-
- - -

RECENT ACTIVITY

- -
-
- -
-
-

- Just Now -
- Paul Rudd purchased an item.
-

-
-
- -
-
- -
-
-

- 2 Minutes Ago -
- James Brown subscribed to your newsletter.
-

-
-
- -
-
- -
-
-

- 3 Hours Ago -
- Diana Kennedy purchased a year subscription.
-

-
-
- -
-
- -
-
-

- 7 Hours Ago -
- Brando Page purchased a year subscription.
-

-
-
- -

TEAM MEMBERS ONLINE

- -
-
- -
-
-

- DIVYA MANIAN
- Available -

-
-
- -
-
- -
-
-

- DJ SHERMAN
- I am Busy -

-
-
- -
-
- -
-
-

- DAN ROGERS
- Available -

-
-
- -
-
- -
-
-

- Zac Sniders
- Available -

-
-
- -
-
-
-
-
-

-
-
-
-
-
-
- -
- -
- -
-
- - -
-
-

- © Copyrights Dashio. All Rights Reserved -

-
- - Created with Dashio template by TemplateMag -
- - - -
-
- -
- - - - - - - - - - - - - - - - - - - - + + + + + + TestLink + + + +
+ + + diff --git a/package.json b/package.json index 0eafedb4ff..21ea384c4f 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,25 @@ { + "name": "testlink-spa", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.3.0", + "axios": "^1.7.0", + "bootstrap": "^5.3.0", + "@bootstrap-icons/font": "^1.11.0", + "chart.js": "^4.4.0", + "@tiptap/vue-3": "^2.4.0", + "@tiptap/starter-kit": "^2.4.0", "tablednd": "^1.0.3" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.3.0" } } diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000000..68d8bc0c50 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,7 @@ + + + diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000000..5dd3b45e31 --- /dev/null +++ b/src/main.js @@ -0,0 +1,9 @@ +import { createApp } from 'vue' +import { createRouter, createWebHashHistory } from 'vue-router' +import 'bootstrap/dist/css/bootstrap.min.css' +import App from './App.vue' +import router from './router/index.js' + +const app = createApp(App) +app.use(router) +app.mount('#app') diff --git a/src/router/index.js b/src/router/index.js new file mode 100644 index 0000000000..7f6b2d8f4b --- /dev/null +++ b/src/router/index.js @@ -0,0 +1,31 @@ +import { createRouter, createWebHashHistory } from 'vue-router' + +const routes = [ + { + path: '/', + component: () => import('../views/DashboardView.vue') + }, + { + path: '/login', + component: () => import('../views/LoginView.vue') + }, + { + path: '/projects/:id', + component: () => import('../views/ProjectView.vue') + }, + { + path: '/projects/:id/runs/:planId', + component: () => import('../views/TestRunView.vue') + }, + { + path: '/milestones', + component: () => import('../views/MilestoneView.vue') + } +] + +const router = createRouter({ + history: createWebHashHistory(), + routes +}) + +export default router diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue new file mode 100644 index 0000000000..c97eb5d380 --- /dev/null +++ b/src/views/DashboardView.vue @@ -0,0 +1,6 @@ + diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue new file mode 100644 index 0000000000..f08352b54e --- /dev/null +++ b/src/views/LoginView.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/views/MilestoneView.vue b/src/views/MilestoneView.vue new file mode 100644 index 0000000000..c492fb7675 --- /dev/null +++ b/src/views/MilestoneView.vue @@ -0,0 +1,5 @@ + diff --git a/src/views/ProjectView.vue b/src/views/ProjectView.vue new file mode 100644 index 0000000000..fe745f98d4 --- /dev/null +++ b/src/views/ProjectView.vue @@ -0,0 +1,5 @@ + diff --git a/src/views/TestRunView.vue b/src/views/TestRunView.vue new file mode 100644 index 0000000000..6bbe40577b --- /dev/null +++ b/src/views/TestRunView.vue @@ -0,0 +1,5 @@ + diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000000..89ad029021 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,29 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + build: { + outDir: 'gui/dist', + emptyOutDir: true, + rollupOptions: { + input: { + main: resolve(__dirname, 'src/main.js') + }, + output: { + entryFileNames: 'assets/[name].js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name]-[ext]' + } + } + }, + server: { + proxy: { + '/lib/api': { + target: 'http://localhost:8090', + changeOrigin: true + } + } + } +}) From 9af182aa1c7a5773923685ba941ad64d17c3dfc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=8B=A4=ED=98=84?= Date: Tue, 24 Mar 2026 14:24:41 +0900 Subject: [PATCH 03/13] =?UTF-8?q?fix:=20Phase=201=20=ED=92=88=EC=A7=88=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=88=20-=20Dockerfile=20npm=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20=EC=B5=9C=EC=A0=81=ED=99=94,=20main.js=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20import=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Dockerfile | 22 ++++++++++++---------- src/main.js | 1 - 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index d5654aeea2..e537de2077 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,28 @@ FROM php:8.2-apache -RUN apt update && apt upgrade -y -RUN apt install -y \ +RUN apt update && apt upgrade -y && \ + apt install -y \ default-mysql-client \ zlib1g-dev \ libpng-dev \ libjpeg-dev \ libfreetype-dev \ - curl -RUN docker-php-ext-install mysqli && \ + curl && \ + docker-php-ext-install mysqli && \ docker-php-ext-enable mysqli && \ docker-php-ext-configure gd --with-freetype --with-jpeg && \ - docker-php-ext-install gd -RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ - apt install -y nodejs -RUN apt clean + docker-php-ext-install gd && \ + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ + apt install -y nodejs && \ + apt clean && \ + rm -rf /var/lib/apt/lists/* WORKDIR /var/www/html +COPY package.json package-lock.json ./ +RUN npm ci COPY . . COPY ./docker/php.ini-production /usr/local/etc/php/conf.d/php.ini - -RUN npm ci && npm run build +RUN npm run build RUN chown -R www-data:www-data /var/www/html/gui/templates_c diff --git a/src/main.js b/src/main.js index 5dd3b45e31..a5d808efdb 100644 --- a/src/main.js +++ b/src/main.js @@ -1,5 +1,4 @@ import { createApp } from 'vue' -import { createRouter, createWebHashHistory } from 'vue-router' import 'bootstrap/dist/css/bootstrap.min.css' import App from './App.vue' import router from './router/index.js' From 3911279e550a73078c2f72db5066dfda809c1287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=8B=A4=ED=98=84?= Date: Tue, 24 Mar 2026 14:29:45 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20Phase=203=20-=20milestones=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20DB=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../1.9.21/mysql/add_milestones.sql | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 install/sql/alter_tables/1.9.21/mysql/add_milestones.sql diff --git a/install/sql/alter_tables/1.9.21/mysql/add_milestones.sql b/install/sql/alter_tables/1.9.21/mysql/add_milestones.sql new file mode 100644 index 0000000000..990e2102e8 --- /dev/null +++ b/install/sql/alter_tables/1.9.21/mysql/add_milestones.sql @@ -0,0 +1,18 @@ +-- TestLink 1.9.21 Migration +-- Add milestones table for milestone tracking feature +-- Date: 2026-03-24 + +CREATE TABLE IF NOT EXISTS `milestones` ( + `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `testproject_id` INT UNSIGNED NOT NULL, + `name` VARCHAR(200) NOT NULL, + `description` TEXT, + `due_date` DATE DEFAULT NULL, + `is_completed` TINYINT(1) NOT NULL DEFAULT 0, + `completion_rate` DECIMAL(5,2) NOT NULL DEFAULT 0.00, + `creation_ts` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modification_ts` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY `idx_milestone_project` (`testproject_id`), + KEY `idx_milestone_due_date` (`due_date`), + KEY `idx_milestone_completed` (`is_completed`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; From c55cc503fbad6cdf216fcbcf72b50a4022685bfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=8B=A4=ED=98=84?= Date: Tue, 24 Mar 2026 14:32:39 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20Phase=202=20-=20REST=20API=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=20(CORS=20=EB=AF=B8=EB=93=A4=EC=9B=A8?= =?UTF-8?q?=EC=96=B4,=20=EB=B0=80=EB=A6=AC=EC=8A=A4=ED=86=A4/=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C/=EC=8B=A4=ED=96=89=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- lib/api/rest/v3/RestApi.class.php | 327 +++++++++++++++++++++++++++++- lib/api/rest/v3/core/routes.php | 34 ++++ lib/api/rest/v3/index.php | 19 ++ 3 files changed, 378 insertions(+), 2 deletions(-) diff --git a/lib/api/rest/v3/RestApi.class.php b/lib/api/rest/v3/RestApi.class.php index fe418bddec..9d3d24606e 100644 --- a/lib/api/rest/v3/RestApi.class.php +++ b/lib/api/rest/v3/RestApi.class.php @@ -1697,7 +1697,330 @@ function byeHTTP500($msg=null) */ function msgFromException($e) { - return $e->getMessage() . - ' - offending line number: ' . $e->getLine(); + return $e->getMessage() . + ' - offending line number: ' . $e->getLine(); } + + /** + * GET /testprojects/{id}/testsuites + * Returns the test suite tree for a project + */ + public function getProjectTestSuites(Request $request, Response $response, $args) { + $op = array('status' => 'ok', 'message' => 'ok', 'items' => null); + try { + $tproject_id = intval($args['id']); + $suites = $this->tsuiteMgr->get_all_by_tproject_id($tproject_id); + $op['items'] = $suites ? array_values($suites) : []; + } catch (Exception $e) { + $op['status'] = 'error'; + $op['message'] = __METHOD__ . ' >> ' . $this->msgFromException($e); + } + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * GET /testsuites/{id}/testcases + * Returns test cases in a test suite + */ + public function getSuiteTestCases(Request $request, Response $response, $args) { + $op = array('status' => 'ok', 'message' => 'ok', 'items' => null); + try { + $tsuite_id = intval($args['id']); + $items = $this->tcaseMgr->get_all_in_suite($tsuite_id, + array('output' => 'array_of_map', 'details' => 'simple')); + $op['items'] = $items ? array_values($items) : []; + } catch (Exception $e) { + $op['status'] = 'error'; + $op['message'] = __METHOD__ . ' >> ' . $this->msgFromException($e); + } + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * GET /testcases/{id} + * Returns a single test case with steps + */ + public function getTestCaseDetail(Request $request, Response $response, $args) { + $op = array('status' => 'ok', 'message' => 'ok', 'item' => null); + try { + $tc_id = intval($args['id']); + $item = $this->tcaseMgr->get_last_version_info($tc_id); + if ($item) { + $item['steps'] = $this->tcaseMgr->get_steps($tc_id, $item['tcversion_id']); + $item['keywords'] = $this->tcaseMgr->get_keywords_map($tc_id, $item['tcversion_id']); + $op['item'] = $item; + } else { + $op['status'] = 'error'; + $op['message'] = "Test case $tc_id not found"; + } + } catch (Exception $e) { + $op['status'] = 'error'; + $op['message'] = __METHOD__ . ' >> ' . $this->msgFromException($e); + } + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * GET /testplans/{id}/executions + * Returns execution results for a test plan + */ + public function getPlanExecutions(Request $request, Response $response, $args) { + $op = array('status' => 'ok', 'message' => 'ok', 'items' => null); + try { + $tplan_id = intval($args['id']); + $filters = array(); + $options = array('output' => 'array_of_map'); + + $items = $this->tplanMgr->get_linked_tcversions($tplan_id, $filters, $options); + $op['items'] = $items ? array_values($items) : []; + } catch (Exception $e) { + $op['status'] = 'error'; + $op['message'] = __METHOD__ . ' >> ' . $this->msgFromException($e); + } + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * GET /testplans/{id}/progress + * Returns progress statistics for dashboard charts + * Returns: passed, failed, blocked, not_run counts and percentages + */ + public function getPlanProgress(Request $request, Response $response, $args) { + $op = array('status' => 'ok', 'message' => 'ok', 'item' => null); + try { + $tplan_id = intval($args['id']); + + $sql = "SELECT e.status, COUNT(*) as cnt + FROM {$this->tables['executions']} e + JOIN {$this->tables['testplan_tcversions']} tptcv + ON tptcv.tcversion_id = e.tcversion_id + AND tptcv.testplan_id = e.testplan_id + WHERE e.testplan_id = {$tplan_id} + AND e.id IN ( + SELECT MAX(id) FROM {$this->tables['executions']} + WHERE testplan_id = {$tplan_id} + GROUP BY tcversion_id, testplan_id, platform_id + ) + GROUP BY e.status"; + + $rows = $this->db->fetchRowsIntoMap($sql, 'status'); + + $stats = array('p' => 0, 'f' => 0, 'b' => 0, 'n' => 0); + if ($rows) { + foreach ($rows as $status => $row) { + if (isset($stats[$status])) { + $stats[$status] = intval($row['cnt']); + } + } + } + + // Count total linked TCs + $totalSql = "SELECT COUNT(*) as cnt FROM {$this->tables['testplan_tcversions']} WHERE testplan_id = {$tplan_id}"; + $totalRow = $this->db->fetchFirstRow($totalSql); + $total = $totalRow ? intval($totalRow['cnt']) : 0; + + $executed = $stats['p'] + $stats['f'] + $stats['b']; + $stats['n'] = max(0, $total - $executed); + $stats['total'] = $total; + $stats['passed'] = $stats['p']; + $stats['failed'] = $stats['f']; + $stats['blocked'] = $stats['b']; + $stats['not_run'] = $stats['n']; + $stats['pass_rate'] = $total > 0 ? round(($stats['p'] / $total) * 100, 1) : 0; + + // Daily execution trend (last 30 days) + $trendSql = "SELECT DATE(execution_ts) as exec_date, status, COUNT(*) as cnt + FROM {$this->tables['executions']} + WHERE testplan_id = {$tplan_id} + AND execution_ts >= DATE_SUB(NOW(), INTERVAL 30 DAY) + GROUP BY DATE(execution_ts), status + ORDER BY exec_date"; + $trendRows = $this->db->get_recordset($trendSql); + $stats['trend'] = $trendRows ? $trendRows : []; + + $op['item'] = $stats; + } catch (Exception $e) { + $op['status'] = 'error'; + $op['message'] = __METHOD__ . ' >> ' . $this->msgFromException($e); + } + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * GET /testprojects/{id}/dashboard + * Returns project-level dashboard statistics + */ + public function getProjectDashboard(Request $request, Response $response, $args) { + $op = array('status' => 'ok', 'message' => 'ok', 'item' => null); + try { + $tproject_id = intval($args['id']); + + // Test plans count + $plansSql = "SELECT COUNT(*) as cnt FROM {$this->tables['testplans']} WHERE testproject_id = {$tproject_id} AND active = 1"; + $plansRow = $this->db->fetchFirstRow($plansSql); + $plansCount = $plansRow ? intval($plansRow['cnt']) : 0; + + // Test suites count + $suitesSql = "SELECT COUNT(*) as cnt FROM {$this->tables['testsuites']} WHERE testproject_id = {$tproject_id}"; + $suitesRow = $this->db->fetchFirstRow($suitesSql); + $suitesCount = $suitesRow ? intval($suitesRow['cnt']) : 0; + + // Test cases count + $tcSql = "SELECT COUNT(DISTINCT n.id) as cnt + FROM {$this->tables['nodes_hierarchy']} n + JOIN {$this->tables['node_types']} nt ON n.node_type_id = nt.id + WHERE nt.description = 'testcase' + AND n.parent_id IN ( + SELECT id FROM {$this->tables['nodes_hierarchy']} n2 + WHERE n2.id = {$tproject_id} OR n2.parent_id = {$tproject_id} + )"; + $tcRow = $this->db->fetchFirstRow($tcSql); + $tcCount = $tcRow ? intval($tcRow['cnt']) : 0; + + // Milestones count + $milestonesCount = 0; + try { + $milestonesSql = "SELECT COUNT(*) as cnt FROM milestones WHERE testproject_id = {$tproject_id}"; + $milestonesRow = $this->db->fetchFirstRow($milestonesSql); + $milestonesCount = $milestonesRow ? intval($milestonesRow['cnt']) : 0; + } catch (Exception $e) { /* table might not exist yet */ } + + $op['item'] = array( + 'plans_count' => $plansCount, + 'suites_count' => $suitesCount, + 'testcases_count' => $tcCount, + 'milestones_count' => $milestonesCount, + ); + } catch (Exception $e) { + $op['status'] = 'error'; + $op['message'] = __METHOD__ . ' >> ' . $this->msgFromException($e); + } + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * GET /milestones/{id} + */ + public function getMilestone(Request $request, Response $response, $args) { + $op = array('status' => 'ok', 'message' => 'ok', 'item' => null); + try { + $id = intval($args['id']); + $sql = "SELECT * FROM milestones WHERE id = {$id}"; + $row = $this->db->fetchFirstRow($sql); + if ($row) { + $op['item'] = $row; + } else { + $op['status'] = 'error'; + $op['message'] = "Milestone $id not found"; + } + } catch (Exception $e) { + $op['status'] = 'error'; + $op['message'] = __METHOD__ . ' >> ' . $this->msgFromException($e); + } + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * GET /testprojects/{id}/milestones + * Returns milestones for a project + */ + public function getProjectMilestones(Request $request, Response $response, $args) { + $op = array('status' => 'ok', 'message' => 'ok', 'items' => null); + try { + $tproject_id = intval($args['id']); + $sql = "SELECT * FROM milestones WHERE testproject_id = {$tproject_id} ORDER BY due_date"; + $rows = $this->db->get_recordset($sql); + $op['items'] = $rows ? $rows : []; + } catch (Exception $e) { + $op['status'] = 'error'; + $op['message'] = __METHOD__ . ' >> ' . $this->msgFromException($e); + } + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * POST /testprojects/{id}/milestones + */ + public function createMilestone(Request $request, Response $response, $args) { + $op = array('status' => 'ok', 'message' => 'ok', 'id' => -1); + try { + $tproject_id = intval($args['id']); + $body = json_decode($request->getBody(), true); + + $name = $this->db->prepare_string($body['name'] ?? ''); + $description = $this->db->prepare_string($body['description'] ?? ''); + $due_date = isset($body['due_date']) ? "'" . $this->db->prepare_string($body['due_date']) . "'" : 'NULL'; + + $sql = "INSERT INTO milestones (testproject_id, name, description, due_date) + VALUES ({$tproject_id}, '{$name}', '{$description}', {$due_date})"; + $this->db->exec_query($sql); + $op['id'] = $this->db->insert_id(); + } catch (Exception $e) { + $op['status'] = 'error'; + $op['message'] = __METHOD__ . ' >> ' . $this->msgFromException($e); + } + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * PUT /milestones/{id} + */ + public function updateMilestone(Request $request, Response $response, $args) { + $op = array('status' => 'ok', 'message' => 'ok'); + try { + $id = intval($args['id']); + $body = json_decode($request->getBody(), true); + + $sets = []; + if (isset($body['name'])) { + $sets[] = "name = '" . $this->db->prepare_string($body['name']) . "'"; + } + if (isset($body['description'])) { + $sets[] = "description = '" . $this->db->prepare_string($body['description']) . "'"; + } + if (isset($body['due_date'])) { + $sets[] = "due_date = '" . $this->db->prepare_string($body['due_date']) . "'"; + } + if (isset($body['is_completed'])) { + $sets[] = "is_completed = " . intval($body['is_completed']); + } + + if (!empty($sets)) { + $sql = "UPDATE milestones SET " . implode(', ', $sets) . " WHERE id = {$id}"; + $this->db->exec_query($sql); + } + } catch (Exception $e) { + $op['status'] = 'error'; + $op['message'] = __METHOD__ . ' >> ' . $this->msgFromException($e); + } + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * DELETE /milestones/{id} + */ + public function deleteMilestone(Request $request, Response $response, $args) { + $op = array('status' => 'ok', 'message' => 'ok'); + try { + $id = intval($args['id']); + $sql = "DELETE FROM milestones WHERE id = {$id}"; + $this->db->exec_query($sql); + } catch (Exception $e) { + $op['status'] = 'error'; + $op['message'] = __METHOD__ . ' >> ' . $this->msgFromException($e); + } + $response->getBody()->write(json_encode($op)); + return $response; + } + } // class end diff --git a/lib/api/rest/v3/core/routes.php b/lib/api/rest/v3/core/routes.php index f0e2404fae..394fe1e93b 100644 --- a/lib/api/rest/v3/core/routes.php +++ b/lib/api/rest/v3/core/routes.php @@ -66,4 +66,38 @@ $app->put('/testplans/{tplan_id}/platforms', array($app->restApi,'addPlatformsToTestPlan')); + // Vue SPA 용 확장 라우트 + $app->get('/testprojects/{id}/testsuites', + array($app->restApi,'getProjectTestSuites')); + + $app->get('/testprojects/{id}/dashboard', + array($app->restApi,'getProjectDashboard')); + + $app->get('/testprojects/{id}/milestones', + array($app->restApi,'getProjectMilestones')); + + $app->post('/testprojects/{id}/milestones', + array($app->restApi,'createMilestone')); + + $app->get('/testsuites/{id}/testcases', + array($app->restApi,'getSuiteTestCases')); + + $app->get('/testcases/{id}', + array($app->restApi,'getTestCaseDetail')); + + $app->get('/testplans/{id}/executions', + array($app->restApi,'getPlanExecutions')); + + $app->get('/testplans/{id}/progress', + array($app->restApi,'getPlanProgress')); + + $app->get('/milestones/{id}', + array($app->restApi,'getMilestone')); + + $app->put('/milestones/{id}', + array($app->restApi,'updateMilestone')); + + $app->delete('/milestones/{id}', + array($app->restApi,'deleteMilestone')); + }; diff --git a/lib/api/rest/v3/index.php b/lib/api/rest/v3/index.php index 10c2e561bb..0e0faabf58 100644 --- a/lib/api/rest/v3/index.php +++ b/lib/api/rest/v3/index.php @@ -83,6 +83,25 @@ $routes = require './core/routes.php'; $routes($app); +// CORS middleware for Vue dev server +$app->add(function (Request $request, $handler) { + $response = $handler->handle($request); + $origin = $request->getHeaderLine('Origin'); + $allowedOrigins = ['http://localhost:5173', 'http://localhost:8090', 'http://localhost']; + if (in_array($origin, $allowedOrigins)) { + $response = $response + ->withHeader('Access-Control-Allow-Origin', $origin) + ->withHeader('Access-Control-Allow-Headers', 'X-Requested-With, Content-Type, Accept, Origin, Authorization, apiKey') + ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS'); + } + return $response; +}); + +// OPTIONS preflight +$app->options('/{routes:.+}', function (Request $request, Response $response) { + return $response; +}); + // Middleware $app->add(array($app->restApi,'authenticate')); From de79700dbb61f5e67890059c9033284c79467d26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=8B=A4=ED=98=84?= Date: Tue, 24 Mar 2026 14:39:00 +0900 Subject: [PATCH 06/13] =?UTF-8?q?fix:=20OPTIONS=20preflight=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=9D=B4=20=EC=9D=B8=EC=A6=9D=20=EB=AF=B8=EB=93=A4?= =?UTF-8?q?=EC=9B=A8=EC=96=B4=EB=A5=BC=20=EC=9A=B0=ED=9A=8C=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- lib/api/rest/v3/RestApi.class.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/api/rest/v3/RestApi.class.php b/lib/api/rest/v3/RestApi.class.php index 9d3d24606e..a2fcbae004 100644 --- a/lib/api/rest/v3/RestApi.class.php +++ b/lib/api/rest/v3/RestApi.class.php @@ -161,8 +161,13 @@ public function __construct() { /** * */ - public function authenticate(Request $request, RequestHandler $handler) + public function authenticate(Request $request, RequestHandler $handler) { + // Allow OPTIONS preflight requests through without authentication + if ($request->getMethod() === 'OPTIONS') { + return $handler->handle($request); + } + $hh = $request->getHeaders(); $apiKey = null; From adb2dde0b66b1d8b5df501e31f7742c2485ddc08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=8B=A4=ED=98=84?= Date: Tue, 24 Mar 2026 14:45:11 +0900 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20Phase=204=20-=20Vue=20SPA=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20(=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=B0=94,=20=ED=97=A4=EB=8D=94,=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=84=B0=20=EA=B0=80=EB=93=9C,=20API=20=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=96=B8=ED=8A=B8,=20=ED=95=B5=EC=8B=AC=20=EB=B7=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/App.vue | 30 +++++- src/api/executions.js | 6 ++ src/api/index.js | 32 +++++++ src/api/milestones.js | 5 + src/api/projects.js | 9 ++ src/api/testcases.js | 4 + src/components/layout/AppHeader.vue | 77 ++++++++++++++++ src/components/layout/AppSidebar.vue | 131 +++++++++++++++++++++++++++ src/router/index.js | 33 +++++-- src/views/DashboardView.vue | 74 ++++++++++++++- src/views/LoginView.vue | 105 ++++++++++++++++----- src/views/ProjectView.vue | 110 +++++++++++++++++++++- 12 files changed, 581 insertions(+), 35 deletions(-) create mode 100644 src/api/executions.js create mode 100644 src/api/index.js create mode 100644 src/api/milestones.js create mode 100644 src/api/projects.js create mode 100644 src/api/testcases.js create mode 100644 src/components/layout/AppHeader.vue create mode 100644 src/components/layout/AppSidebar.vue diff --git a/src/App.vue b/src/App.vue index 68d8bc0c50..789ddbc550 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,7 +1,33 @@ + + diff --git a/src/api/executions.js b/src/api/executions.js new file mode 100644 index 0000000000..4803d2cbed --- /dev/null +++ b/src/api/executions.js @@ -0,0 +1,6 @@ +import api from './index.js' + +export const createExecution = (data) => api.post('/executions', data) +export const getPlanExecutions = (planId) => api.get(`/testplans/${planId}/executions`) +export const getPlanProgress = (planId) => api.get(`/testplans/${planId}/progress`) +export const getPlanBuilds = (planId) => api.get(`/testplans/${planId}/builds`) diff --git a/src/api/index.js b/src/api/index.js new file mode 100644 index 0000000000..8f40ae9faf --- /dev/null +++ b/src/api/index.js @@ -0,0 +1,32 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: '/lib/api/rest/v3', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } +}) + +// API 키를 localStorage에서 주입 +api.interceptors.request.use(config => { + const apiKey = localStorage.getItem('tl_apikey') + if (apiKey) { + config.headers['apiKey'] = apiKey + } + return config +}) + +// 401 시 로그인 페이지로 리다이렉트 +api.interceptors.response.use( + response => response, + error => { + if (error.response?.status === 401) { + localStorage.removeItem('tl_apikey') + window.location.hash = '#/login' + } + return Promise.reject(error) + } +) + +export default api diff --git a/src/api/milestones.js b/src/api/milestones.js new file mode 100644 index 0000000000..de224d1520 --- /dev/null +++ b/src/api/milestones.js @@ -0,0 +1,5 @@ +import api from './index.js' + +export const getMilestone = (id) => api.get(`/milestones/${id}`) +export const updateMilestone = (id, data) => api.put(`/milestones/${id}`, data) +export const deleteMilestone = (id) => api.delete(`/milestones/${id}`) diff --git a/src/api/projects.js b/src/api/projects.js new file mode 100644 index 0000000000..fa9f01f1f9 --- /dev/null +++ b/src/api/projects.js @@ -0,0 +1,9 @@ +import api from './index.js' + +export const getProjects = () => api.get('/testprojects') +export const getProject = (id) => api.get(`/testprojects/${id}`) +export const getProjectTestSuites = (id) => api.get(`/testprojects/${id}/testsuites`) +export const getProjectTestPlans = (id) => api.get(`/testprojects/${id}/testplans`) +export const getProjectDashboard = (id) => api.get(`/testprojects/${id}/dashboard`) +export const getProjectMilestones = (id) => api.get(`/testprojects/${id}/milestones`) +export const createProjectMilestone = (id, data) => api.post(`/testprojects/${id}/milestones`, data) diff --git a/src/api/testcases.js b/src/api/testcases.js new file mode 100644 index 0000000000..32db3570b8 --- /dev/null +++ b/src/api/testcases.js @@ -0,0 +1,4 @@ +import api from './index.js' + +export const getSuiteTestCases = (suiteId) => api.get(`/testsuites/${suiteId}/testcases`) +export const getTestCase = (id) => api.get(`/testcases/${id}`) diff --git a/src/components/layout/AppHeader.vue b/src/components/layout/AppHeader.vue new file mode 100644 index 0000000000..2354ec608a --- /dev/null +++ b/src/components/layout/AppHeader.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue new file mode 100644 index 0000000000..bb4a5c85b3 --- /dev/null +++ b/src/components/layout/AppSidebar.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/src/router/index.js b/src/router/index.js index 7f6b2d8f4b..0169fd5808 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,25 +1,34 @@ import { createRouter, createWebHashHistory } from 'vue-router' const routes = [ - { - path: '/', - component: () => import('../views/DashboardView.vue') - }, { path: '/login', + name: 'login', component: () => import('../views/LoginView.vue') }, + { + path: '/', + name: 'dashboard', + component: () => import('../views/DashboardView.vue'), + meta: { requiresAuth: true } + }, { path: '/projects/:id', - component: () => import('../views/ProjectView.vue') + name: 'project', + component: () => import('../views/ProjectView.vue'), + meta: { requiresAuth: true } }, { path: '/projects/:id/runs/:planId', - component: () => import('../views/TestRunView.vue') + name: 'testrun', + component: () => import('../views/TestRunView.vue'), + meta: { requiresAuth: true } }, { path: '/milestones', - component: () => import('../views/MilestoneView.vue') + name: 'milestones', + component: () => import('../views/MilestoneView.vue'), + meta: { requiresAuth: true } } ] @@ -28,4 +37,14 @@ const router = createRouter({ routes }) +router.beforeEach((to) => { + const isAuthenticated = !!localStorage.getItem('tl_apikey') + if (to.meta.requiresAuth && !isAuthenticated) { + return { name: 'login' } + } + if (to.name === 'login' && isAuthenticated) { + return { name: 'dashboard' } + } +}) + export default router diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue index c97eb5d380..18a5905815 100644 --- a/src/views/DashboardView.vue +++ b/src/views/DashboardView.vue @@ -1,6 +1,74 @@ + + + + diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue index f08352b54e..b40393797b 100644 --- a/src/views/LoginView.vue +++ b/src/views/LoginView.vue @@ -1,31 +1,94 @@ + + diff --git a/src/views/ProjectView.vue b/src/views/ProjectView.vue index fe745f98d4..77d0c61ca5 100644 --- a/src/views/ProjectView.vue +++ b/src/views/ProjectView.vue @@ -1,5 +1,111 @@ + + + + From 3582a96253fdff5c1beb70bd648c3dc3983028d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=8B=A4=ED=98=84?= Date: Tue, 24 Mar 2026 14:52:00 +0900 Subject: [PATCH 08/13] =?UTF-8?q?feat:=20Phase=205=20-=20=EC=9D=B8?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20UI=20(ExecutionStatus,=20TestRunInline)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/components/execution/ExecutionStatus.vue | 61 ++++ src/components/execution/TestRunInline.vue | 314 +++++++++++++++++++ src/views/TestRunView.vue | 93 +++++- 3 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 src/components/execution/ExecutionStatus.vue create mode 100644 src/components/execution/TestRunInline.vue diff --git a/src/components/execution/ExecutionStatus.vue b/src/components/execution/ExecutionStatus.vue new file mode 100644 index 0000000000..17943e78fb --- /dev/null +++ b/src/components/execution/ExecutionStatus.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/src/components/execution/TestRunInline.vue b/src/components/execution/TestRunInline.vue new file mode 100644 index 0000000000..88f2dcec47 --- /dev/null +++ b/src/components/execution/TestRunInline.vue @@ -0,0 +1,314 @@ + + + + + diff --git a/src/views/TestRunView.vue b/src/views/TestRunView.vue index 6bbe40577b..073ddca679 100644 --- a/src/views/TestRunView.vue +++ b/src/views/TestRunView.vue @@ -1,5 +1,94 @@ + + + + From b6f26a6aea5576456c799d12927f146d3f0a4bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=8B=A4=ED=98=84?= Date: Tue, 24 Mar 2026 14:56:51 +0900 Subject: [PATCH 09/13] =?UTF-8?q?feat:=20Phase=207=20-=20=EB=B0=80?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=86=A4=20UI=20(=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8,=20=EA=B8=B0=ED=95=9C=20=EA=B0=95=EC=A1=B0,=20CRUD=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/components/milestones/MilestoneForm.vue | 66 +++++++ src/components/milestones/MilestoneList.vue | 148 +++++++++++++++ src/views/MilestoneView.vue | 191 +++++++++++++++++++- 3 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 src/components/milestones/MilestoneForm.vue create mode 100644 src/components/milestones/MilestoneList.vue diff --git a/src/components/milestones/MilestoneForm.vue b/src/components/milestones/MilestoneForm.vue new file mode 100644 index 0000000000..ca356ae0e5 --- /dev/null +++ b/src/components/milestones/MilestoneForm.vue @@ -0,0 +1,66 @@ + + + diff --git a/src/components/milestones/MilestoneList.vue b/src/components/milestones/MilestoneList.vue new file mode 100644 index 0000000000..a06542f0e4 --- /dev/null +++ b/src/components/milestones/MilestoneList.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/src/views/MilestoneView.vue b/src/views/MilestoneView.vue index c492fb7675..22cdd59780 100644 --- a/src/views/MilestoneView.vue +++ b/src/views/MilestoneView.vue @@ -1,5 +1,192 @@ + + + + From b2a2e4d0d07b053f972c20508cc65458efd4ae9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=8B=A4=ED=98=84?= Date: Tue, 24 Mar 2026 14:57:50 +0900 Subject: [PATCH 10/13] =?UTF-8?q?feat:=20Phase=206=20-=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=B0=A8=ED=8A=B8=20(Chart.js=20?= =?UTF-8?q?=EB=8F=84=EB=84=9B/=EB=B0=94=20=EC=B0=A8=ED=8A=B8,=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EA=B0=B1=EC=8B=A0,=20=ED=99=9C=EB=8F=99=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/components/dashboard/ActivityFeed.vue | 57 ++++++ src/components/dashboard/ProgressChart.vue | 199 +++++++++++++++++++++ src/views/ProjectView.vue | 50 +++++- 3 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 src/components/dashboard/ActivityFeed.vue create mode 100644 src/components/dashboard/ProgressChart.vue diff --git a/src/components/dashboard/ActivityFeed.vue b/src/components/dashboard/ActivityFeed.vue new file mode 100644 index 0000000000..45eba64e69 --- /dev/null +++ b/src/components/dashboard/ActivityFeed.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/src/components/dashboard/ProgressChart.vue b/src/components/dashboard/ProgressChart.vue new file mode 100644 index 0000000000..cb5c27a397 --- /dev/null +++ b/src/components/dashboard/ProgressChart.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/src/views/ProjectView.vue b/src/views/ProjectView.vue index 77d0c61ca5..4e0ff742aa 100644 --- a/src/views/ProjectView.vue +++ b/src/views/ProjectView.vue @@ -48,20 +48,41 @@
테스트 플랜이 없습니다.
-
-
{{ plan.name }}
+
{{ plan.name }}
{{ plan.notes || '' }}
- - {{ plan.active == '1' ? '활성' : '비활성' }} - -
+
+ + {{ plan.active == '1' ? '활성' : '비활성' }} + + 실행 +
+ + + + + +
+
{{ selectedPlanName }} 현황
+
+
+ +
+
+ +
@@ -72,12 +93,27 @@ import { ref, onMounted } from 'vue' import { RouterLink, useRoute } from 'vue-router' import { getProject, getProjectTestPlans, getProjectDashboard } from '../api/projects.js' +import { getPlanExecutions } from '../api/executions.js' +import ProgressChart from '../components/dashboard/ProgressChart.vue' +import ActivityFeed from '../components/dashboard/ActivityFeed.vue' const route = useRoute() const loading = ref(true) const project = ref(null) const testPlans = ref([]) const dashboard = ref({}) +const selectedPlanId = ref(null) +const selectedPlanName = ref('') +const planExecutions = ref([]) + +async function selectPlan(plan) { + selectedPlanId.value = plan.id + selectedPlanName.value = plan.name + try { + const res = await getPlanExecutions(plan.id) + planExecutions.value = res.data?.items || [] + } catch (e) {} +} onMounted(async () => { try { From d1be711f7864eb74c28304206dd851ef437dd13c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=8B=A4=ED=98=84?= Date: Tue, 24 Mar 2026 15:04:31 +0900 Subject: [PATCH 11/13] =?UTF-8?q?feat:=20Phase=208=20-=20TC=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EA=B8=B0=20(TipTap,=20=EC=8A=A4=ED=85=9D=20=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=EC=95=A4=EB=93=9C=EB=A1=AD,=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EC=A0=80=EC=9E=A5=202=EC=B4=88=20debounce)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/components/testcases/TestCaseEditor.vue | 250 ++++++++++++++++++++ src/components/testcases/TestCaseList.vue | 79 +++++++ src/components/testcases/TestSuiteTree.vue | 39 +++ src/router/index.js | 6 + src/views/TestSuiteView.vue | 125 ++++++++++ 5 files changed, 499 insertions(+) create mode 100644 src/components/testcases/TestCaseEditor.vue create mode 100644 src/components/testcases/TestCaseList.vue create mode 100644 src/components/testcases/TestSuiteTree.vue create mode 100644 src/views/TestSuiteView.vue diff --git a/src/components/testcases/TestCaseEditor.vue b/src/components/testcases/TestCaseEditor.vue new file mode 100644 index 0000000000..18881b7643 --- /dev/null +++ b/src/components/testcases/TestCaseEditor.vue @@ -0,0 +1,250 @@ + + + + + diff --git a/src/components/testcases/TestCaseList.vue b/src/components/testcases/TestCaseList.vue new file mode 100644 index 0000000000..245c7f40f5 --- /dev/null +++ b/src/components/testcases/TestCaseList.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/src/components/testcases/TestSuiteTree.vue b/src/components/testcases/TestSuiteTree.vue new file mode 100644 index 0000000000..d927b7ff1b --- /dev/null +++ b/src/components/testcases/TestSuiteTree.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/src/router/index.js b/src/router/index.js index 0169fd5808..03b290e559 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -29,6 +29,12 @@ const routes = [ name: 'milestones', component: () => import('../views/MilestoneView.vue'), meta: { requiresAuth: true } + }, + { + path: '/projects/:id/suites', + name: 'testsuites', + component: () => import('../views/TestSuiteView.vue'), + meta: { requiresAuth: true } } ] diff --git a/src/views/TestSuiteView.vue b/src/views/TestSuiteView.vue new file mode 100644 index 0000000000..c9716ca41f --- /dev/null +++ b/src/views/TestSuiteView.vue @@ -0,0 +1,125 @@ + + + + + From 364af17c20bb54366faea6b4f225f2ce25686b90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=8B=A4=ED=98=84?= Date: Tue, 24 Mar 2026 15:07:18 +0900 Subject: [PATCH 12/13] =?UTF-8?q?feat:=20Phase=209=20-=20Docker=20healthch?= =?UTF-8?q?eck,=20.dockerignore,=20API=20=EC=9D=91=EB=8B=B5=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .dockerignore | 6 ++++++ docker-compose.yml | 8 +++++++- src/components/layout/AppHeader.vue | 10 ++++++++-- src/views/DashboardView.vue | 3 ++- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index d7ad356d73..e5898d79b1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,9 @@ .env.example docker-compose.yml Dockerfile +.worktrees/ +node_modules/ +.git/ +.gitignore +*.md +gui/dist/ diff --git a/docker-compose.yml b/docker-compose.yml index f4235d21f7..26fb568049 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,12 @@ services: - MYSQL_DATABASE=testlink volumes: - mysql:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pteste"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s maildev: image: maildev/maildev:latest @@ -32,7 +38,7 @@ services: restart: unless-stopped depends_on: db: - condition: service_started + condition: service_healthy maildev: condition: service_started networks: diff --git a/src/components/layout/AppHeader.vue b/src/components/layout/AppHeader.vue index 2354ec608a..faefda9209 100644 --- a/src/components/layout/AppHeader.vue +++ b/src/components/layout/AppHeader.vue @@ -32,14 +32,20 @@ const maskedKey = computed(() => { onMounted(async () => { try { const res = await getProjects() - projects.value = res.data || [] + const data = res.data + projects.value = Array.isArray(data) ? data : (data?.item || data?.items || []) } catch (e) { // not logged in } }) function onProjectChange() { - emit('project-changed', projects.value.find(p => p.id == selectedProjectId.value)) + const project = projects.value.find(p => p.id == selectedProjectId.value) + if (project) { + localStorage.setItem('tl_current_project_id', project.id) + localStorage.setItem('tl_current_project_name', project.name) + } + emit('project-changed', project) if (selectedProjectId.value) { router.push(`/projects/${selectedProjectId.value}`) } diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue index 18a5905815..a716943f2f 100644 --- a/src/views/DashboardView.vue +++ b/src/views/DashboardView.vue @@ -45,7 +45,8 @@ const loading = ref(true) onMounted(async () => { try { const res = await getProjects() - projects.value = res.data || [] + const data = res.data + projects.value = Array.isArray(data) ? data : (data?.item || data?.items || []) } catch (e) { console.error(e) } finally { From d674e692df146c125d8a02b0cffa3269618e712f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=8B=A4=ED=98=84?= Date: Tue, 24 Mar 2026 15:11:09 +0900 Subject: [PATCH 13/13] =?UTF-8?q?fix:=20@bootstrap-icons/font=20=E2=86=92?= =?UTF-8?q?=20bootstrap-icons=20(=EC=98=AC=EB=B0=94=EB=A5=B8=20npm=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=EB=AA=85=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 21ea384c4f..15e86b87f9 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "vue-router": "^4.3.0", "axios": "^1.7.0", "bootstrap": "^5.3.0", - "@bootstrap-icons/font": "^1.11.0", + "bootstrap-icons": "^1.11.0", "chart.js": "^4.4.0", "@tiptap/vue-3": "^2.4.0", "@tiptap/starter-kit": "^2.4.0",