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/.gitignore b/.gitignore index 36447cbe5f..c6009e69e1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ config_db.inc.php **/._* upload_area/** .env +.worktrees/ diff --git a/Dockerfile b/Dockerfile index 7be6ef9c08..e537de2077 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,28 @@ -FROM php:7.4-apache +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 -RUN docker-php-ext-install mysqli && \ + libfreetype-dev \ + 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 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 run build -RUN chown -R www-data:www-data /var/www/html/gui/templates_c \ No newline at end of file +RUN chown -R www-data:www-data /var/www/html/gui/templates_c 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/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 -

-
-
- -
-
-
-
-
-

-
-
-
-
-
-
- -
- -
- -
-
- - - - -
- - - - - - - - - - - - - - - - - - - - + + + + + + TestLink + + + +
+ + + 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; diff --git a/lib/api/rest/v3/RestApi.class.php b/lib/api/rest/v3/RestApi.class.php index fe418bddec..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; @@ -1697,7 +1702,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')); diff --git a/package.json b/package.json index 0eafedb4ff..15e86b87f9 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": "^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..789ddbc550 --- /dev/null +++ b/src/App.vue @@ -0,0 +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/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/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/components/layout/AppHeader.vue b/src/components/layout/AppHeader.vue new file mode 100644 index 0000000000..faefda9209 --- /dev/null +++ b/src/components/layout/AppHeader.vue @@ -0,0 +1,83 @@ + + + + + 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/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/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/main.js b/src/main.js new file mode 100644 index 0000000000..a5d808efdb --- /dev/null +++ b/src/main.js @@ -0,0 +1,8 @@ +import { createApp } from 'vue' +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..03b290e559 --- /dev/null +++ b/src/router/index.js @@ -0,0 +1,56 @@ +import { createRouter, createWebHashHistory } from 'vue-router' + +const routes = [ + { + path: '/login', + name: 'login', + component: () => import('../views/LoginView.vue') + }, + { + path: '/', + name: 'dashboard', + component: () => import('../views/DashboardView.vue'), + meta: { requiresAuth: true } + }, + { + path: '/projects/:id', + name: 'project', + component: () => import('../views/ProjectView.vue'), + meta: { requiresAuth: true } + }, + { + path: '/projects/:id/runs/:planId', + name: 'testrun', + component: () => import('../views/TestRunView.vue'), + meta: { requiresAuth: true } + }, + { + path: '/milestones', + name: 'milestones', + component: () => import('../views/MilestoneView.vue'), + meta: { requiresAuth: true } + }, + { + path: '/projects/:id/suites', + name: 'testsuites', + component: () => import('../views/TestSuiteView.vue'), + meta: { requiresAuth: true } + } +] + +const router = createRouter({ + history: createWebHashHistory(), + 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 new file mode 100644 index 0000000000..a716943f2f --- /dev/null +++ b/src/views/DashboardView.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue new file mode 100644 index 0000000000..b40393797b --- /dev/null +++ b/src/views/LoginView.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/src/views/MilestoneView.vue b/src/views/MilestoneView.vue new file mode 100644 index 0000000000..22cdd59780 --- /dev/null +++ b/src/views/MilestoneView.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/src/views/ProjectView.vue b/src/views/ProjectView.vue new file mode 100644 index 0000000000..4e0ff742aa --- /dev/null +++ b/src/views/ProjectView.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/src/views/TestRunView.vue b/src/views/TestRunView.vue new file mode 100644 index 0000000000..073ddca679 --- /dev/null +++ b/src/views/TestRunView.vue @@ -0,0 +1,94 @@ + + + + + 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 @@ + + + + + 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 + } + } + } +})